diff --git a/LICENSE b/LICENSE index ff328e27..84a05165 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,6 @@ Copyright © 2013 CyberAgent, Inc. Copyright © 2016 The OpenSTF Project +Copyright © 2019 Orange SA Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/lib/cli/api/index.js b/lib/cli/api/index.js index 6654f953..78ef9468 100644 --- a/lib/cli/api/index.js +++ b/lib/cli/api/index.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports.command = 'api' module.exports.describe = 'Start an API unit.' @@ -18,6 +22,18 @@ module.exports.builder = function(yargs) { , array: true , demand: true }) + .option('connect-push-dev', { + alias: 'pd' + , describe: 'Device-side ZeroMQ PULL endpoint to connect to.' + , array: true + , demand: true + }) + .option('connect-sub-dev', { + alias: 'sd' + , describe: 'Device-side ZeroMQ PUB endpoint to connect to.' + , array: true + , demand: true + }) .option('port', { alias: 'p' , describe: 'The port to bind to.' @@ -53,6 +69,8 @@ module.exports.handler = function(argv) { , endpoints: { push: argv.connectPush , sub: argv.connectSub + , pushdev: argv.connectPushDev + , subdev: argv.connectSubDev } }) } diff --git a/lib/cli/generate-fake-group/index.js b/lib/cli/generate-fake-group/index.js new file mode 100644 index 00000000..aaecfbf3 --- /dev/null +++ b/lib/cli/generate-fake-group/index.js @@ -0,0 +1,39 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports.command = 'generate-fake-group' + +module.exports.builder = function(yargs) { + return yargs + .strict() + .option('n', { + alias: 'number' + , describe: 'How many groups to create.' + , type: 'number' + , default: 1 + }) +} + +module.exports.handler = function(argv) { + var logger = require('../../util/logger') + var log = logger.createLogger('cli:generate-fake-group') + var fake = require('../../util/fakegroup') + var n = argv.number + + function next() { + return fake.generate().then(function(email) { + log.info('Created fake group "%s"', email) + return --n ? next() : null + }) + } + + return next() + .then(function() { + process.exit(0) + }) + .catch(function(err) { + log.fatal('Fake group creation had an error:', err.stack) + process.exit(1) + }) +} diff --git a/lib/cli/generate-fake-user/index.js b/lib/cli/generate-fake-user/index.js new file mode 100644 index 00000000..581c31d1 --- /dev/null +++ b/lib/cli/generate-fake-user/index.js @@ -0,0 +1,39 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports.command = 'generate-fake-user' + +module.exports.builder = function(yargs) { + return yargs + .strict() + .option('n', { + alias: 'number' + , describe: 'How many users to create.' + , type: 'number' + , default: 1 + }) +} + +module.exports.handler = function(argv) { + var logger = require('../../util/logger') + var log = logger.createLogger('cli:generate-fake-user') + var fake = require('../../util/fakeuser') + var n = argv.number + + function next() { + return fake.generate().then(function(email) { + log.info('Created fake user "%s"', email) + return --n ? next() : null + }) + } + + return next() + .then(function() { + process.exit(0) + }) + .catch(function(err) { + log.fatal('Fake user creation had an error:', err.stack) + process.exit(1) + }) +} diff --git a/lib/cli/groups-engine/index.js b/lib/cli/groups-engine/index.js new file mode 100644 index 00000000..83841218 --- /dev/null +++ b/lib/cli/groups-engine/index.js @@ -0,0 +1,51 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports.command = 'groups-engine' + +module.exports.describe = 'Start the groups engine unit.' + +module.exports.builder = function(yargs) { + return yargs + .env('STF_GROUPS_ENGINE') + .strict() + .option('connect-push', { + alias: 'c' + , describe: 'App-side ZeroMQ PULL endpoint to connect to.' + , array: true + , demand: true + }) + .option('connect-sub', { + alias: 'u' + , describe: 'App-side ZeroMQ PUB endpoint to connect to.' + , array: true + , demand: true + }) + .option('connect-push-dev', { + alias: 'pd' + , describe: 'Device-side ZeroMQ PULL endpoint to connect to.' + , array: true + , demand: true + }) + .option('connect-sub-dev', { + alias: 'sd' + , describe: 'Device-side ZeroMQ PUB endpoint to connect to.' + , array: true + , demand: true + }) + .epilog('Each option can be be overwritten with an environment variable ' + + 'by converting the option to uppercase, replacing dashes with ' + + 'underscores and prefixing it with `STF_GROUPS_ENGINE_` .)') +} + +module.exports.handler = function(argv) { + return require('../../units/groups-engine')({ + endpoints: { + push: argv.connectPush + , sub: argv.connectSub + , pushdev: argv.connectPushDev + , subdev: argv.connectSubDev + } + }) +} diff --git a/lib/cli/index.js b/lib/cli/index.js index 48b57e03..3f7c9ee0 100644 --- a/lib/cli/index.js +++ b/lib/cli/index.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var yargs = require('yargs') var Promise = require('bluebird') @@ -12,9 +16,12 @@ var _argv = yargs.usage('Usage: $0 [options]') .command(require('./auth-oauth2')) .command(require('./auth-openid')) .command(require('./auth-saml2')) + .command(require('./groups-engine')) .command(require('./device')) .command(require('./doctor')) .command(require('./generate-fake-device')) + .command(require('./generate-fake-user')) + .command(require('./generate-fake-group')) .command(require('./local')) .command(require('./log-rethinkdb')) .command(require('./migrate')) diff --git a/lib/cli/local/index.js b/lib/cli/local/index.js index 11fcfb27..4e1b0b97 100644 --- a/lib/cli/local/index.js +++ b/lib/cli/local/index.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports.command = 'local [serial..]' module.exports.describe = 'Start a complete local development environment.' @@ -337,6 +341,17 @@ module.exports.handler = function(argv) { , '--secret', argv.authSecret , '--connect-push', argv.bindAppPull , '--connect-sub', argv.bindAppPub + , '--connect-push-dev', argv.bindDevPull + , '--connect-sub-dev', argv.bindDevPub + ]) + + // groups engine + , procutil.fork(path.resolve(__dirname, '..'), [ + 'groups-engine' + , '--connect-push', argv.bindAppPull + , '--connect-sub', argv.bindAppPub + , '--connect-push-dev', argv.bindDevPull + , '--connect-sub-dev', argv.bindDevPub ]) // websocket diff --git a/lib/cli/migrate/index.js b/lib/cli/migrate/index.js index f5954c0d..ce507289 100644 --- a/lib/cli/migrate/index.js +++ b/lib/cli/migrate/index.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports.command = 'migrate' module.exports.describe = 'Migrates the database to the latest version.' @@ -10,13 +14,44 @@ module.exports.handler = function() { var logger = require('../../util/logger') var log = logger.createLogger('cli:migrate') var db = require('../../db') + var dbapi = require('../../db/api') + const apiutil = require('../../util/apiutil') + const Promise = require('bluebird') return db.setup() .then(function() { - process.exit(0) + return new Promise(function(resolve, reject) { + setTimeout(function() { + return dbapi.getGroupByIndex(apiutil.ROOT, 'privilege').then(function(group) { + if (!group) { + const env = { + STF_ROOT_GROUP_NAME: 'Common' + , STF_ADMIN_NAME: 'administrator' + , STF_ADMIN_EMAIL: 'administrator@fakedomain.com' + } + for (const i in env) { + if (process.env[i]) { + env[i] = process.env[i] + } + } + return dbapi.createBootStrap(env) + } + return group + }) + .then(function() { + resolve(true) + }) + .catch(function(err) { + reject(err) + }) + }, 1000) + }) }) .catch(function(err) { log.fatal('Migration had an error:', err.stack) process.exit(1) }) + .finally(function() { + process.exit(0) + }) } diff --git a/lib/db/api.js b/lib/db/api.js index 8cfe9881..88ed195f 100644 --- a/lib/db/api.js +++ b/lib/db/api.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var r = require('rethinkdb') var util = require('util') @@ -6,6 +10,11 @@ var wireutil = require('../wire/util') var dbapi = Object.create(null) +const uuid = require('uuid') +const apiutil = require('../util/apiutil') +const Promise = require('bluebird') +const _ = require('lodash') + dbapi.DuplicateSecondaryIndexError = function DuplicateSecondaryIndexError() { Error.call(this) this.name = 'DuplicateSecondaryIndexError' @@ -18,6 +27,869 @@ dbapi.close = function(options) { return db.close(options) } +dbapi.unlockBookingObjects = function() { + return Promise.all([ + db.run(r.table('users').update({groups: {lock: false}})) + , db.run(r.table('devices').update({group: {lock: false}})) + , db.run(r.table('groups').update({lock: {admin: false, user: false}})) + ]) +} + +dbapi.createBootStrap = function(env) { + const now = Date.now() + + return dbapi.createGroup({ + name: env.STF_ROOT_GROUP_NAME + , owner: { + email: env.STF_ADMIN_EMAIL + , name: env.STF_ADMIN_NAME + } + , users: [env.STF_ADMIN_EMAIL] + , privilege: apiutil.ROOT + , class: apiutil.STANDARD + , repetitions: 0 + , duration: 0 + , isActive: true + , state: apiutil.READY + , dates: [{ + start: new Date(now) + , stop: new Date(now + apiutil.ONE_YEAR) + }] + , envUserGroupsNumber: apiutil.MAX_USER_GROUPS_NUMBER + , envUserGroupsDuration: apiutil.MAX_USER_GROUPS_DURATION + , envUserGroupsRepetitions: apiutil.MAX_USER_GROUPS_REPETITIONS + }) + .then(function(group) { + return dbapi.saveUserAfterLogin({ + name: group.owner.name + , email: group.owner.email + , ip: '127.0.0.1' + }) + .then(function() { + return dbapi.reserveUserGroupInstance(group.owner.email) + }) + }) +} + +dbapi.deleteDevice = function(serial) { + return db.run(r.table('devices').get(serial).delete()) +} + +dbapi.deleteUser = function(email) { + return db.run(r.table('users').get(email).delete()) +} + +dbapi.getReadyGroupsOrderByIndex = function(index) { + return db + .run(r.table('groups') + .orderBy({index: index}) + .filter(function(group) { + return group('state').ne(apiutil.PENDING) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getGroupsByIndex = function(value, index) { + return db.run(r.table('groups').getAll(value, {index: index})) + .then(function(cursor) { + return cursor.toArray() + }) +} + + +dbapi.getGroupByIndex = function(value, index) { + return dbapi.getGroupsByIndex(value, index) + .then(function(array) { + return array[0] + }) +} + +dbapi.getGroupsByUser = function(email) { + return db + .run(r.table('groups') + .filter(function(group) { + return group('users').contains(email) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getGroup = function(id) { + return db.run(r.table('groups').get(id)) +} + +dbapi.getGroups = function() { + return db.run(r.table('groups')) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getUsers = function() { + return db.run(r.table('users')) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getEmails = function() { + return db.run(r.table('users').filter(function(user) { + return user('privilege').ne(apiutil.ADMIN) + }) + .getField('email')) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.addGroupUser = function(id, email) { + return Promise.all([ + db.run(r.table('groups') + .get(id) + .update({users: r.row('users').setInsert(email)})) + , db.run(r.table('users') + .get(email) + .update({groups: {subscribed: r.row('groups')('subscribed').setInsert(id)}})) + ]) + .then(function(statss) { + return statss[0].unchanged ? 'unchanged' : 'added' + }) +} + +dbapi.removeGroupUser = function(id, email) { + return Promise.all([ + db.run(r.table('groups') + .get(id) + .update({users: r.row('users').setDifference([email])})) + , db.run(r.table('users') + .get(email) + .update({groups: {subscribed: r.row('groups')('subscribed').setDifference([id])}})) + ]) + .then(function() { + return 'deleted' + }) +} + +dbapi.lockBookableDevice = function(groups, serial) { + function wrappedlockBookableDevice() { + return db.run(r.table('devices').get(serial).update({group: {lock: + r.branch( + r.row('group')('lock') + .eq(false) + .and(r.row('group')('class') + .ne(apiutil.STANDARD)) + .and(r.expr(groups) + .setIntersection([r.row('group')('origin')]) + .isEmpty() + .not()) + , true + , r.row('group')('lock')) + }}, {returnChanges: true})) + .then(function(stats) { + return apiutil.lockDeviceResult(stats, dbapi.loadBookableDevice, groups, serial) + }) + } + + return apiutil.setIntervalWrapper( + wrappedlockBookableDevice + , 10 + , Math.random() * 500 + 50) +} + +dbapi.lockDeviceByOrigin = function(groups, serial) { + function wrappedlockDeviceByOrigin() { + return db.run(r.table('devices').get(serial).update({group: {lock: + r.branch( + r.row('group')('lock') + .eq(false) + .and(r.expr(groups) + .setIntersection([r.row('group')('origin')]) + .isEmpty() + .not()) + , true + , r.row('group')('lock')) + }}, {returnChanges: true})) + .then(function(stats) { + return apiutil.lockDeviceResult(stats, dbapi.loadDeviceByOrigin, groups, serial) + }) + } + + return apiutil.setIntervalWrapper( + wrappedlockDeviceByOrigin + , 10 + , Math.random() * 500 + 50) +} + +dbapi.addOriginGroupDevice = function(group, serial) { + return db + .run(r.table('groups') + .get(group.id) + .update({devices: r.row('devices').setInsert(serial)})) + .then(function() { + return dbapi.getGroup(group.id) + }) +} + +dbapi.removeOriginGroupDevice = function(group, serial) { + return db + .run(r.table('groups') + .get(group.id) + .update({devices: r.row('devices').setDifference([serial])})) + .then(function() { + return dbapi.getGroup(group.id) + }) +} + +dbapi.addGroupDevices = function(group, serials) { + const duration = apiutil.computeDuration(group, serials.length) + + return dbapi.updateUserGroupDuration(group.owner.email, group.duration, duration) + .then(function(stats) { + if (stats.replaced) { + return dbapi.updateGroup( + group.id + , { + duration: duration + , devices: _.union(group.devices, serials) + }) + } + return Promise.reject('quota is reached') + }) +} + +dbapi.removeGroupDevices = function(group, serials) { + const duration = apiutil.computeDuration(group, -serials.length) + + return dbapi.updateUserGroupDuration(group.owner.email, group.duration, duration) + .then(function() { + return dbapi.updateGroup( + group.id + , { + duration: duration + , devices: _.difference(group.devices, serials) + }) + }) +} + +function setLockOnDevice(serial, state) { + return db.run(r.table('devices').get(serial).update({group: {lock: + r.branch( + r.row('group')('lock').eq(!state) + , state + , r.row('group')('lock')) + }})) +} + +dbapi.lockDevice = function(serial) { + return setLockOnDevice(serial, true) +} + +dbapi.unlockDevice = function(serial) { + return setLockOnDevice(serial, false) +} + +function setLockOnUser(email, state) { + return db.run(r.table('users').get(email).update({groups: {lock: + r.branch( + r.row('groups')('lock').eq(!state) + , state + , r.row('groups')('lock')) + }}, {returnChanges: true})) +} + +dbapi.lockUser = function(email) { + function wrappedlockUser() { + return setLockOnUser(email, true) + .then(function(stats) { + return apiutil.lockResult(stats) + }) + } + + return apiutil.setIntervalWrapper( + wrappedlockUser + , 10 + , Math.random() * 500 + 50) +} + +dbapi.unlockUser = function(email) { + return setLockOnUser(email, false) +} + +dbapi.lockGroupByOwner = function(email, id) { + function wrappedlockGroupByOwner() { + return dbapi.getRootGroup().then(function(group) { + return db.run(r.table('groups').get(id).update({lock: {user: + r.branch( + r.row('lock')('admin') + .eq(false) + .and(r.row('lock')('user').eq(false)) + .and(r.row('owner')('email') + .eq(email) + .or(r.expr(email) + .eq(group.owner.email))) + , true + , r.row('lock')('user')) + }}, {returnChanges: true})) + }) + .then(function(stats) { + const result = apiutil.lockResult(stats) + + if (!result.status) { + return dbapi.getGroupAsOwnerOrAdmin(email, id).then(function(group) { + if (!group) { + result.data.locked = false + result.status = true + } + return result + }) + } + return result + }) + } + + return apiutil.setIntervalWrapper( + wrappedlockGroupByOwner + , 10 + , Math.random() * 500 + 50) +} + +dbapi.lockGroup = function(id) { + function wrappedlockGroup() { + return db.run(r.table('groups').get(id).update({lock: {user: + r.branch( + r.row('lock')('admin') + .eq(false) + .and(r.row('lock')('user') + .eq(false)) + , true + , r.row('lock')('user')) + }})) + .then(function(stats) { + return apiutil.lockResult(stats) + }) + } + + return apiutil.setIntervalWrapper( + wrappedlockGroup + , 10 + , Math.random() * 500 + 50) +} + +dbapi.unlockGroup = function(id) { + return db.run(r.table('groups').get(id).update({lock: {user: false}})) +} + +dbapi.adminLockGroup = function(id, lock) { + function wrappedAdminLockGroup() { + return db + .run(r.table('groups') + .get(id) + .update({lock: {user: true, admin: true}}, {returnChanges: true})) + .then(function(stats) { + const result = {} + + if (stats.replaced) { + result.status = + stats.changes[0].new_val.lock.admin && !stats.changes[0].old_val.lock.user + if (result.status) { + result.data = true + lock.group = stats.changes[0].new_val + } + } + else if (stats.skipped) { + result.status = true + } + return result + }) + } + + return apiutil.setIntervalWrapper( + wrappedAdminLockGroup + , 10 + , Math.random() * 500 + 50) +} + +dbapi.adminUnlockGroup = function(lock) { + if (lock.group) { + return db + .run(r.table('groups') + .get(lock.group.id) + .update({lock: {user: false, admin: false}})) + } + return true +} + +dbapi.getRootGroup = function() { + return dbapi.getGroupByIndex(apiutil.ROOT, 'privilege').then(function(group) { + if (!group) { + throw new Error('Root group not found') + } + return group + }) +} + +dbapi.getUserGroup = function(email, id) { + return db.run(r.table('groups').getAll(id).filter(function(group) { + return group('users').contains(email) + })) + .then(function(cursor) { + return cursor.toArray() + }) + .then(function(groups) { + return groups[0] + }) +} + +dbapi.getUserGroups = function(email) { + return db + .run(r.table('groups') + .filter(function(group) { + return group('users').contains(email) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getOnlyUserGroups = function(email) { + return db + .run(r.table('groups') + .filter(function(group) { + return group('owner')('email') + .ne(email) + .and(group('users').contains(email)) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getTransientGroups = function() { + return db + .run(r.table('groups') + .filter(function(group) { + return group('class') + .ne(apiutil.BOOKABLE) + .and(group('class').ne(apiutil.STANDARD)) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getDeviceTransientGroups = function(serial) { + return db + .run(r.table('groups') + .filter(function(group) { + return group('class') + .ne(apiutil.BOOKABLE) + .and(group('class').ne(apiutil.STANDARD)) + .and(group('devices').contains(serial)) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.isDeviceBooked = function(serial) { + return dbapi.getDeviceTransientGroups(serial) + .then(function(groups) { + return groups.length > 0 + }) +} + +dbapi.isRemoveGroupUserAllowed = function(email, targetGroup) { + if (targetGroup.class !== apiutil.BOOKABLE) { + return Promise.resolve(true) + } + return db.run( + r.table('groups') + .getAll(email, {index: 'owner'}) + .filter(function(group) { + return group('class') + .ne(apiutil.BOOKABLE) + .and(group('class').ne(apiutil.STANDARD)) + .and(r.expr(targetGroup.devices) + .setIntersection(group('devices')) + .isEmpty() + .not()) + })) + .then(function(cursor) { + return cursor.toArray() + }) + .then(function(groups) { + return groups.length === 0 + }) +} + +dbapi.isUpdateDeviceOriginGroupAllowed = function(serial, targetGroup) { + return dbapi.getDeviceTransientGroups(serial) + .then(function(groups) { + if (groups.length) { + if (targetGroup.class === apiutil.STANDARD) { + return false + } + for (const group of groups) { + if (targetGroup.users.indexOf(group.owner.email) < 0) { + return false + } + } + } + return true + }) +} + +dbapi.getDeviceGroups = function(serial) { + return db + .run(r.table('groups') + .filter(function(group) { + return group('devices').contains(serial) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.getGroupAsOwnerOrAdmin = function(email, id) { + return dbapi.getGroup(id).then(function(group) { + if (group) { + if (email === group.owner.email) { + return group + } + return dbapi.loadUser(email).then(function(user) { + if (user && user.privilege === apiutil.ADMIN) { + return group + } + return false + }) + } + return false + }) +} + +dbapi.getOwnerGroups = function(email) { + return dbapi.getRootGroup().then(function(group) { + if (email === group.owner.email) { + return dbapi.getGroups() + } + return dbapi.getGroupsByIndex(email, 'owner') + }) +} + +dbapi.createGroup = function(data) { + const id = util.format('%s', uuid.v4()).replace(/-/g, '') + + return db.run(r.table('groups').insert( + Object.assign(data, { + id: id + , users: _.union(data.users, [data.owner.email]) + , devices: [] + , createdAt: r.now() + , lock: { + user: false + , admin: false + } + , ticket: null + }))) + .then(function() { + return dbapi.getGroup(id) + }) +} + +dbapi.createUserGroup = function(data) { + return dbapi.reserveUserGroupInstance(data.owner.email).then(function(stats) { + if (stats.replaced) { + return dbapi.getRootGroup().then(function(rootGroup) { + data.users = [rootGroup.owner.email] + return dbapi.createGroup(data).then(function(group) { + return Promise.all([ + dbapi.addGroupUser(group.id, group.owner.email) + , dbapi.addGroupUser(group.id, rootGroup.owner.email) + ]) + .then(function() { + return dbapi.getGroup(group.id) + }) + }) + }) + } + return false + }) +} + +dbapi.updateGroup = function(id, data) { + return db.run(r.table('groups').get(id).update(data)) + .then(function() { + return dbapi.getGroup(id) + }) +} + +dbapi.reserveUserGroupInstance = function(email) { + return db.run(r.table('users').get(email) + .update({groups: {quotas: {consumed: {number: + r.branch( + r.row('groups')('quotas')('consumed')('number') + .add(1) + .le(r.row('groups')('quotas')('allocated')('number')) + , r.row('groups')('quotas')('consumed')('number') + .add(1) + , r.row('groups')('quotas')('consumed')('number')) + }}}}) + ) +} + +dbapi.releaseUserGroupInstance = function(email) { + return db.run(r.table('users').get(email) + .update({groups: {quotas: {consumed: {number: + r.branch( + r.row('groups')('quotas')('consumed')('number').ge(1) + , r.row('groups')('quotas')('consumed')('number').sub(1) + , r.row('groups')('quotas')('consumed')('number')) + }}}}) + ) +} + +dbapi.updateUserGroupDuration = function(email, oldDuration, newDuration) { + return db.run(r.table('users').get(email) + .update({groups: {quotas: {consumed: {duration: + r.branch( + r.row('groups')('quotas')('consumed')('duration') + .sub(oldDuration).add(newDuration) + .le(r.row('groups')('quotas')('allocated')('duration')) + , r.row('groups')('quotas')('consumed')('duration') + .sub(oldDuration).add(newDuration) + , r.row('groups')('quotas')('consumed')('duration')) + }}}}) + ) +} + +dbapi.updateUserGroupsQuotas = function(email, duration, number, repetitions) { + return db + .run(r.table('users').get(email) + .update({groups: {quotas: {allocated: { + duration: + r.branch( + r.expr(duration) + .ne(null) + .and(r.row('groups')('quotas')('consumed')('duration') + .le(duration)) + .and(r.expr(number) + .eq(null) + .or(r.row('groups')('quotas')('consumed')('number') + .le(number))) + , duration + , r.row('groups')('quotas')('allocated')('duration')) + , number: + r.branch( + r.expr(number) + .ne(null) + .and(r.row('groups')('quotas')('consumed')('number') + .le(number)) + .and(r.expr(duration) + .eq(null) + .or(r.row('groups')('quotas')('consumed')('duration') + .le(duration))) + , number + , r.row('groups')('quotas')('allocated')('number')) + } + , repetitions: + r.branch( + r.expr(repetitions).ne(null) + , repetitions + , r.row('groups')('quotas')('repetitions')) + }}}, {returnChanges: true})) +} + +dbapi.updateDefaultUserGroupsQuotas = function(email, duration, number, repetitions) { + return db.run(r.table('users').get(email) + .update({groups: {quotas: { + defaultGroupsDuration: + r.branch( + r.expr(duration).ne(null) + , duration + , r.row('groups')('quotas')('defaultGroupsDuration')) + , defaultGroupsNumber: + r.branch( + r.expr(number).ne(null) + , number + , r.row('groups')('quotas')('defaultGroupsNumber')) + , defaultGroupsRepetitions: + r.branch( + r.expr(repetitions).ne(null) + , repetitions + , r.row('groups')('quotas')('defaultGroupsRepetitions')) + }}}, {returnChanges: true})) +} + +dbapi.updateDeviceCurrentGroupFromOrigin = function(serial) { + return db.run(r.table('devices').get(serial)).then(function(device) { + return db.run(r.table('groups').get(device.group.origin)).then(function(group) { + return db.run(r.table('devices').get(serial).update({group: { + id: r.row('group')('origin') + , name: r.row('group')('originName') + , owner: group.owner + , lifeTime: group.dates[0] + , class: group.class + , repetitions: group.repetitions + }})) + }) + }) +} + +dbapi.askUpdateDeviceOriginGroup = function(serial, group, signature) { + return db.run(r.table('groups').get(group.id) + .update({ticket: { + serial: serial + , signature: signature + }}) + ) +} + +dbapi.updateDeviceOriginGroup = function(serial, group) { + return db.run(r.table('devices').get(serial) + .update({group: { + origin: group.id + , originName: group.name + , id: r.branch( + r.row('group')('id').eq(r.row('group')('origin')) + , group.id + , r.row('group')('id')) + , name: r.branch( + r.row('group')('id').eq(r.row('group')('origin')) + , group.name + , r.row('group')('name')) + , owner: r.branch( + r.row('group')('id').eq(r.row('group')('origin')) + , group.owner + , r.row('group')('owner')) + , lifeTime: r.branch( + r.row('group')('id').eq(r.row('group')('origin')) + , group.dates[0] + , r.row('group')('lifeTime')) + , class: r.branch( + r.row('group')('id').eq(r.row('group')('origin')) + , group.class + , r.row('group')('class')) + , repetitions: r.branch( + r.row('group')('id').eq(r.row('group')('origin')) + , group.repetitions + , r.row('group')('repetitions')) + }}) + ) + .then(function() { + return db.run(r.table('devices').get(serial)) + }) +} + +dbapi.updateDeviceCurrentGroup = function(serial, group) { + return db.run(r.table('devices').get(serial) + .update({group: { + id: group.id + , name: group.name + , owner: group.owner + , lifeTime: group.dates[0] + , class: group.class + , repetitions: group.repetitions + }}) + ) +} + +dbapi.updateUserGroup = function(group, data) { + return dbapi.updateUserGroupDuration(group.owner.email, group.duration, data.duration) + .then(function(stats) { + if (stats.replaced || stats.unchanged && group.duration === data.duration) { + return dbapi.updateGroup(group.id, data) + } + return false + }) +} + +dbapi.deleteGroup = function(id) { + return db.run(r.table('groups').get(id).delete()) +} + +dbapi.deleteUserGroup = function(id) { + function deleteUserGroup(group) { + return dbapi.deleteGroup(group.id) + .then(function() { + return Promise.map(group.users, function(email) { + return dbapi.removeGroupUser(group.id, email) + }) + }) + .then(function() { + return dbapi.releaseUserGroupInstance(group.owner.email) + }) + .then(function() { + return dbapi.updateUserGroupDuration(group.owner.email, group.duration, 0) + }) + .then(function() { + return 'deleted' + }) + } + + return dbapi.getGroup(id).then(function(group) { + if (group.privilege !== apiutil.ROOT) { + return deleteUserGroup(group) + } + return 'forbidden' + }) +} + +dbapi.createUser = function(email, name, ip) { + return dbapi.getRootGroup().then(function(group) { + return dbapi.loadUser(group.owner.email).then(function(adminUser) { + return db.run(r.table('users').insert({ + email: email + , name: name + , ip: ip + , group: wireutil.makePrivateChannel() + , lastLoggedInAt: r.now() + , createdAt: r.now() + , forwards: [] + , settings: {} + , privilege: adminUser ? apiutil.USER : apiutil.ADMIN + , groups: { + subscribed: [] + , lock: false + , quotas: { + allocated: { + number: adminUser ? + adminUser.groups.quotas.defaultGroupsNumber : + group.envUserGroupsNumber + , duration: adminUser ? + adminUser.groups.quotas.defaultGroupsDuration : + group.envUserGroupsDuration + } + , consumed: { + number: 0 + , duration: 0 + } + , defaultGroupsNumber: adminUser ? 0 : group.envUserGroupsNumber + , defaultGroupsDuration: adminUser ? 0 : group.envUserGroupsDuration + , defaultGroupsRepetitions: adminUser ? 0 : group.envUserGroupsRepetitions + , repetitions: adminUser ? + adminUser.groups.quotas.defaultGroupsRepetitions : + group.envUserGroupsRepetitions + } + } + }, {returnChanges: true})) + .then(function(stats) { + if (stats.inserted) { + return dbapi.addGroupUser(group.id, email).then(function() { + return dbapi.loadUser(email).then(function(user) { + stats.changes[0].new_val = user + return stats + }) + }) + } + return stats + }) + }) + }) +} + dbapi.saveUserAfterLogin = function(user) { return db.run(r.table('users').get(user.email).update({ name: user.name @@ -26,16 +898,7 @@ dbapi.saveUserAfterLogin = function(user) { })) .then(function(stats) { if (stats.skipped) { - return db.run(r.table('users').insert({ - email: user.email - , name: user.name - , ip: user.ip - , group: wireutil.makePrivateChannel() - , lastLoggedInAt: r.now() - , createdAt: r.now() - , forwards: [] - , settings: {} - })) + return dbapi.createUser(user.email, user.name, user.ip) } return stats }) @@ -122,9 +985,15 @@ dbapi.lookupUserByVncAuthResponse = function(response, serial) { } dbapi.loadUserDevices = function(email) { - return db.run(r.table('devices').getAll(email, { - index: 'owner' - })) + return db.run(r.table('users').get(email).getField('groups')) + .then(function(groups) { + return db.run(r.table('devices').filter(function(device) { + return r.expr(groups.subscribed) + .contains(device('group')('id')) + .and(device('owner')('email').eq(email)) + .and(device('present').eq(true)) + })) + }) } dbapi.saveDeviceLog = function(serial, entry) { @@ -143,7 +1012,7 @@ dbapi.saveDeviceLog = function(serial, entry) { dbapi.saveDeviceInitialState = function(serial, device) { var data = { - present: false + present: true , presenceChangedAt: r.now() , provider: device.provider , owner: null @@ -155,15 +1024,32 @@ dbapi.saveDeviceInitialState = function(serial, device) { , remoteConnectUrl: null , usage: null } - return db.run(r.table('devices').get(serial).update(data)) - .then(function(stats) { - if (stats.skipped) { + return db.run(r.table('devices').get(serial).update(data)).then(function(stats) { + if (stats.skipped) { + return dbapi.getRootGroup().then(function(group) { data.serial = serial data.createdAt = r.now() - return db.run(r.table('devices').insert(data)) - } - return stats - }) + data.group = { + id: group.id + , name: group.name + , lifeTime: group.dates[0] + , owner: group.owner + , origin: group.id + , class: group.class + , repetitions: group.repetitions + , originName: group.name + , lock: false + } + return db.run(r.table('devices').insert(data)).then(function() { + dbapi.addOriginGroupDevice(group, serial) + }) + }) + } + return true + }) + .then(function() { + return db.run(r.table('devices').get(serial)) + }) } dbapi.setDeviceConnectUrl = function(serial, url) { @@ -327,8 +1213,44 @@ dbapi.saveDeviceIdentity = function(serial, identity) { })) } -dbapi.loadDevices = function() { - return db.run(r.table('devices')) +dbapi.loadDevices = function(groups) { + return db.run(r.table('devices').filter(function(device) { + return r.expr(groups).contains(device('group')('id')) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.loadDevicesByOrigin = function(groups) { + return db.run(r.table('devices').filter(function(device) { + return r.expr(groups).contains(device('group')('origin')) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.loadBookableDevices = function(groups) { + return db.run(r.table('devices').filter(function(device) { + return r.expr(groups) + .contains(device('group')('origin')) + .and(device('group')('class').ne(apiutil.STANDARD)) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.loadStandardDevices = function(groups) { + return db.run(r.table('devices').filter(function(device) { + return r.expr(groups) + .contains(device('group')('origin')) + .and(device('group')('class').eq(apiutil.STANDARD)) + })) + .then(function(cursor) { + return cursor.toArray() + }) } dbapi.loadPresentDevices = function() { @@ -337,17 +1259,49 @@ dbapi.loadPresentDevices = function() { })) } -dbapi.loadDevice = function(serial) { +dbapi.loadDeviceBySerial = function(serial) { return db.run(r.table('devices').get(serial)) } +dbapi.loadDevice = function(groups, serial) { + return db.run(r.table('devices').getAll(serial).filter(function(device) { + return r.expr(groups).contains(device('group')('id')) + })) +} + +dbapi.loadBookableDevice = function(groups, serial) { + return db.run(r.table('devices').getAll(serial).filter(function(device) { + return r.expr(groups) + .contains(device('group')('origin')) + .and(device('group')('class').ne(apiutil.STANDARD)) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + +dbapi.loadDeviceByOrigin = function(groups, serial) { + return db.run(r.table('devices').getAll(serial).filter(function(device) { + return r.expr(groups).contains(device('group')('origin')) + })) + .then(function(cursor) { + return cursor.toArray() + }) +} + dbapi.saveUserAccessToken = function(email, token) { return db.run(r.table('accessTokens').insert({ email: email , id: token.id , title: token.title , jwt: token.jwt - })) + }, {returnChanges: true})) +} + +dbapi.removeUserAccessTokens = function(email) { + return db.run(r.table('accessTokens').getAll(email, { + index: 'email' + }).delete()) } dbapi.removeUserAccessToken = function(email, title) { @@ -356,6 +1310,10 @@ dbapi.removeUserAccessToken = function(email, title) { }).filter({title: title}).delete()) } +dbapi.removeAccessToken = function(id) { + return db.run(r.table('accessTokens').get(id).delete()) +} + dbapi.loadAccessTokens = function(email) { return db.run(r.table('accessTokens').getAll(email, { index: 'email' diff --git a/lib/db/tables.js b/lib/db/tables.js index 231f5971..1f1e9798 100644 --- a/lib/db/tables.js +++ b/lib/db/tables.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var r = require('rethinkdb') module.exports = { @@ -49,9 +53,30 @@ module.exports = { return device('provider')('channel') } } + , group: { + indexFunction: function(device) { + return device('group')('id') + } + } } } , logs: { primaryKey: 'id' } +, groups: { + primaryKey: 'id' + , indexes: { + privilege: null + , owner: { + indexFunction: function(group) { + return group('owner')('email') + } + } + , startTime: { + indexFunction: function(group) { + return group('dates').nth(0)('start') + } + } + } + } } diff --git a/lib/units/api/controllers/devices.js b/lib/units/api/controllers/devices.js index ba236b4f..7b423a16 100644 --- a/lib/units/api/controllers/devices.js +++ b/lib/units/api/controllers/devices.js @@ -1,79 +1,527 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var _ = require('lodash') var Promise = require('bluebird') var dbapi = require('../../../db/api') var logger = require('../../../util/logger') -var datautil = require('../../../util/datautil') - var log = logger.createLogger('api:controllers:devices') -module.exports = { - getDevices: getDevices -, getDeviceBySerial: getDeviceBySerial +const apiutil = require('../../../util/apiutil') +const lockutil = require('../../../util/lockutil') +const util = require('util') +const uuid = require('uuid') +const wire = require('../../../wire') +const wireutil = require('../../../wire/util') +const wirerouter = require('../../../wire/router') + +/* ------------------------------------ PRIVATE FUNCTIONS ------------------------------- */ + +function filterGenericDevices(req, res, devices) { + apiutil.respond(res, 200, 'Devices Information', { + devices: devices.map(function(device) { + return apiutil.filterDevice(req, device) + }) + }) } -function getDevices(req, res) { - var fields = req.swagger.params.fields.value +function getGenericDevices(req, res, loadDevices) { + loadDevices(req.user.groups.subscribed).then(function(devices) { + filterGenericDevices(req, res, devices) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to load device list: ', err.stack) + }) +} - dbapi.loadDevices() - .then(function(cursor) { - return Promise.promisify(cursor.toArray, cursor)() - .then(function(list) { - var deviceList = [] - - list.forEach(function(device) { - datautil.normalize(device, req.user) - var responseDevice = device - - if (fields) { - responseDevice = _.pick(device, fields.split(',')) - } - deviceList.push(responseDevice) - }) - - res.json({ - success: true - , devices: deviceList - }) - }) +function getDeviceFilteredGroups(serial, fields, bookingOnly) { + return dbapi.getDeviceGroups(serial).then(function(groups) { + return Promise.map(groups, function(group) { + return !bookingOnly || !apiutil.isOriginGroup(group.class) ? + group : + 'filtered' }) - .catch(function(err) { - log.error('Failed to load device list: ', err.stack) - res.status(500).json({ - success: false + .then(function(groups) { + return _.without(groups, 'filtered').map(function(group) { + if (fields) { + return _.pick(apiutil.publishGroup(group), fields.split(',')) + } + return apiutil.publishGroup(group) }) }) + }) +} + +function extractStandardizableDevices(devices) { + return dbapi.getTransientGroups().then(function(groups) { + return Promise.map(devices, function(device) { + return Promise.map(groups, function(group) { + if (group.devices.indexOf(device.serial) > -1) { + return Promise.reject('booked') + } + return true + }) + .then(function() { + return device + }) + .catch(function(err) { + if (err !== 'booked') { + throw err + } + return err + }) + }) + .then(function(devices) { + return _.without(devices, 'booked') + }) + }) +} + +function getStandardizableDevices(req, res) { + dbapi.loadDevicesByOrigin(req.user.groups.subscribed).then(function(devices) { + extractStandardizableDevices(devices).then(function(devices) { + filterGenericDevices(req, res, devices) + }) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to load device list: ', err.stack) + }) +} + +function removeDevice(serial, req, res) { + const presentState = req.swagger.params.present.value + const bookingState = req.swagger.params.booked.value + const notesState = req.swagger.params.annotated.value + const controllingState = req.swagger.params.controlled.value + const anyPresentState = typeof presentState === 'undefined' + const anyBookingState = typeof bookingState === 'undefined' + const anyNotesState = typeof notesState === 'undefined' + const anyControllingState = typeof controllingState === 'undefined' + const lock = {} + + function deleteGroupDevice(email, id) { + const lock = {} + + return dbapi.lockGroupByOwner(email, id).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + const group = lock.group = stats.changes[0].new_val + + if (group.devices.indexOf(serial) > -1) { + return apiutil.isOriginGroup(group.class) ? + dbapi.removeOriginGroupDevice(group, serial) : + dbapi.removeGroupDevices(group, [serial]) + } + return group + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) + } + + function deleteDeviceInDatabase() { + function wrappedDeleteDeviceInDatabase() { + const result = { + status: false + , data: 'not deleted' + } + + return dbapi.loadDeviceBySerial(serial).then(function(device) { + if (device && device.group.id === device.group.origin) { + return deleteGroupDevice(device.group.owner.email, device.group.id) + .then(function(group) { + if (group !== 'not found') { + return dbapi.deleteDevice(serial).then(function() { + result.status = true + result.data = 'deleted' + }) + } + return false + }) + } + return false + }) + .then(function() { + return result + }) + } + return apiutil.setIntervalWrapper( + wrappedDeleteDeviceInDatabase + , 10 + , Math.random() * 500 + 50) + } + + return dbapi.lockDeviceByOrigin(req.user.groups.subscribed, serial).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + const device = lock.device = stats.changes[0].new_val + + if (!anyPresentState && device.present !== presentState || + !anyControllingState && (device.owner === null) === controllingState || + !anyNotesState && + (typeof device.notes !== 'undefined' && device.notes !== '') !== notesState || + !anyBookingState && (device.group.id !== device.group.origin && !bookingState || + device.group.class === apiutil.STANDARD && bookingState)) { + return 'unchanged' + } + if (device.group.class === apiutil.STANDARD) { + return deleteDeviceInDatabase() + } + return dbapi.getDeviceTransientGroups(serial).then(function(groups) { + if (groups.length && !anyBookingState && !bookingState) { + return 'unchanged' + } + return Promise.each(groups, function(group) { + return deleteGroupDevice(group.owner.email, group.id) + }) + .then(function() { + if (!groups.length && !anyBookingState && bookingState) { + return 'unchanged' + } + return deleteDeviceInDatabase() + }) + }) + }) + .finally(function() { + lockutil.unlockDevice(lock) + }) +} + +/* ------------------------------------ PUBLIC FUNCTIONS ------------------------------- */ + +function getDevices(req, res) { + const target = req.swagger.params.target.value + + switch(target) { + case apiutil.BOOKABLE: + getGenericDevices(req, res, dbapi.loadBookableDevices) + break + case apiutil.ORIGIN: + getGenericDevices(req, res, dbapi.loadDevicesByOrigin) + break + case apiutil.STANDARD: + getGenericDevices(req, res, dbapi.loadStandardDevices) + break + case apiutil.STANDARDIZABLE: + getStandardizableDevices(req, res) + break + default: + getGenericDevices(req, res, dbapi.loadDevices) + } } function getDeviceBySerial(req, res) { var serial = req.swagger.params.serial.value var fields = req.swagger.params.fields.value - dbapi.loadDevice(serial) - .then(function(device) { - if (!device) { - return res.status(404).json({ - success: false - , description: 'Device not found' + dbapi.loadDevice(req.user.groups.subscribed, serial) + .then(function(cursor) { + cursor.next(function(err, device) { + if (err) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + let responseDevice = apiutil.publishDevice(device, req.user) + + if (fields) { + responseDevice = _.pick(device, fields.split(',')) + } + res.json({ + success: true + , device: responseDevice }) - } - - datautil.normalize(device, req.user) - var responseDevice = device - - if (fields) { - responseDevice = _.pick(device, fields.split(',')) - } - - res.json({ - success: true - , device: responseDevice }) }) .catch(function(err) { - log.error('Failed to load device "%s": ', req.params.serial, err.stack) + log.error('Failed to load device "%s": ', serial, err.stack) res.status(500).json({ success: false }) }) } + +function getDeviceGroups(req, res) { + const serial = req.swagger.params.serial.value + const fields = req.swagger.params.fields.value + + dbapi.loadDevice(req.user.groups.subscribed, serial).then(function(cursor) { + return cursor.toArray() + }) + .then(function(devices) { + if (!devices.length) { + apiutil.respond(res, 404, 'Not Found (device)') + } + else { + getDeviceFilteredGroups(serial, fields, false) + .then(function(groups) { + return apiutil.respond(res, 200, 'Groups Information', {groups: groups}) + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get device groups: ', err.stack) + }) +} + +function getDeviceBookings(req, res) { + const serial = req.swagger.params.serial.value + const fields = req.swagger.params.fields.value + + dbapi.loadDevice(req.user.groups.subscribed, serial).then(function(cursor) { + return cursor.toArray() + }) + .then(function(devices) { + if (!devices.length) { + apiutil.respond(res, 404, 'Not Found (device)') + } + else { + getDeviceFilteredGroups(serial, fields, true) + .then(function(bookings) { + apiutil.respond(res, 200, 'Bookings Information', {bookings: bookings}) + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get device bookings: ', err.stack) + }) +} + +function addOriginGroupDevices(req, res) { + const serials = apiutil.getBodyParameter(req.body, 'serials') + const fields = apiutil.getQueryParameter(req.swagger.params.fields) + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'device' : 'devices' + const lock = {} + + function askUpdateDeviceOriginGroup(group, serial) { + return new Promise(function(resolve, reject) { + const signature = util.format('%s', uuid.v4()).replace(/-/g, '') + let messageListener + const responseTimer = setTimeout(function() { + req.options.channelRouter.removeListener(wireutil.global, messageListener) + apiutil.respond(res, 504, 'Gateway Time-out') + reject('timeout') + }, 5000) + + messageListener = wirerouter() + .on(wire.DeviceOriginGroupMessage, function(channel, message) { + if (message.signature === signature) { + clearTimeout(responseTimer) + req.options.channelRouter.removeListener(wireutil.global, messageListener) + dbapi.loadDeviceBySerial(serial).then(function(device) { + if (fields) { + resolve(_.pick(apiutil.publishDevice(device, req.user), fields.split(','))) + } + else { + resolve(apiutil.publishDevice(device, req.user)) + } + }) + } + }) + .handler() + + req.options.channelRouter.on(wireutil.global, messageListener) + return dbapi.askUpdateDeviceOriginGroup(serial, group, signature) + }) + } + + function updateDeviceOriginGroup(group, serial) { + const lock = {} + + return dbapi.lockDeviceByOrigin(req.user.groups.subscribed, serial).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + lock.device = stats.changes[0].new_val + + return dbapi.isUpdateDeviceOriginGroupAllowed(serial, group) + .then(function(updatingAllowed) { + if (!updatingAllowed) { + apiutil.respond(res, 403, 'Forbidden (device is currently booked)') + return Promise.reject('booked') + } + return askUpdateDeviceOriginGroup(group, serial) + }) + }) + .finally(function() { + lockutil.unlockDevice(lock) + }) + } + + function updateDevicesOriginGroup(group, serials) { + let results = [] + + return Promise.each(serials, function(serial) { + return updateDeviceOriginGroup(group, serial).then(function(result) { + results.push(result) + }) + }) + .then(function() { + const result = target === 'device' ? {device: {}} : {devices: []} + + results = _.without(results, 'unchanged') + if (!results.length) { + return apiutil.respond(res, 200, `Unchanged (${target})`, result) + } + results = _.without(results, 'not found') + if (!results.length) { + return apiutil.respond(res, 404, `Not Found (${target})`) + } + if (target === 'device') { + result.device = results[0] + } + else { + result.devices = results + } + return apiutil.respond(res, 200, `Updated (${target})`, result) + }) + .catch(function(err) { + if (err !== 'booked' && err !== 'timeout' && err !== 'busy') { + throw err + } + }) + } + + return lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + const group = lock.group + + if (!apiutil.isOriginGroup(group.class)) { + return apiutil.respond(res, 400, 'Bad Request (this group cannot act as an origin one)') + } + if (typeof serials !== 'undefined') { + return updateDevicesOriginGroup( + group + , _.without(serials.split(','), '').filter(function(serial) { + return group.devices.indexOf(serial) < 0 + }) + ) + } + return dbapi.loadDevicesByOrigin(req.user.groups.subscribed).then(function(devices) { + if (group.class === apiutil.BOOKABLE) { + return devices + } + return extractStandardizableDevices(devices) + }) + .then(function(devices) { + const serials = [] + + devices.forEach(function(device) { + if (group.devices.indexOf(device.serial) < 0) { + serials.push(device.serial) + } + }) + return updateDevicesOriginGroup(group, serials) + }) + } + return false + }) + .catch(function(err) { + apiutil.internalError(res, `Failed to update ${target} origin group: `, err.stack) + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) +} + +function addOriginGroupDevice(req, res) { + apiutil.redirectApiWrapper('serial', addOriginGroupDevices, req, res) +} + +function removeOriginGroupDevices(req, res) { + const lock = {} + + return lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + const group = lock.group + + if (!apiutil.checkBodyParameter(req.body, 'serials')) { + req.body = {serials: group.devices.join()} + } + return dbapi.getRootGroup().then(function(group) { + req.swagger.params.id = {value: group.id} + return addOriginGroupDevices(req, res) + }) + } + return false + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) +} + +function removeOriginGroupDevice(req, res) { + apiutil.redirectApiWrapper('serial', removeOriginGroupDevices, req, res) +} + +function deleteDevices(req, res) { + const serials = apiutil.getBodyParameter(req.body, 'serials') + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'device' : 'devices' + + function removeDevices(serials) { + let results = [] + + return Promise.each(serials, function(serial) { + return removeDevice(serial, req, res).then(function(result) { + if (result === 'not deleted') { + apiutil.respond(res, 503, 'Server too busy [code: 2], please try again later') + return Promise.reject('busy') + } + return results.push(result) + }) + }) + .then(function() { + results = _.without(results, 'unchanged') + if (!results.length) { + return apiutil.respond(res, 200, `Unchanged (${target})`) + } + if (!_.without(results, 'not found').length) { + return apiutil.respond(res, 404, `Not Found (${target})`) + } + return apiutil.respond(res, 200, `Deleted (${target})`) + }) + .catch(function(err) { + if (err !== 'busy') { + throw err + } + }) + } + + (function() { + if (typeof serials === 'undefined') { + return dbapi.loadDevicesByOrigin(req.user.groups.subscribed).then(function(devices) { + return removeDevices(devices.map(function(device) { + return device.serial + })) + }) + } + else { + return removeDevices(_.without(serials.split(','), '')) + } + })() + .catch(function(err) { + apiutil.internalError(res, `Failed to delete ${target}: `, err.stack) + }) +} + +function deleteDevice(req, res) { + apiutil.redirectApiWrapper('serial', deleteDevices, req, res) +} + +module.exports = { + getDevices: getDevices +, getDeviceBySerial: getDeviceBySerial +, getDeviceGroups: getDeviceGroups +, getDeviceBookings: getDeviceBookings +, addOriginGroupDevice: addOriginGroupDevice +, addOriginGroupDevices: addOriginGroupDevices +, removeOriginGroupDevice: removeOriginGroupDevice +, removeOriginGroupDevices: removeOriginGroupDevices +, deleteDevice: deleteDevice +, deleteDevices: deleteDevices +} diff --git a/lib/units/api/controllers/groups.js b/lib/units/api/controllers/groups.js new file mode 100644 index 00000000..63866998 --- /dev/null +++ b/lib/units/api/controllers/groups.js @@ -0,0 +1,931 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const _ = require('lodash') +const dbapi = require('../../../db/api') +const apiutil = require('../../../util/apiutil') +const lockutil = require('../../../util/lockutil') +const util = require('util') +const uuid = require('uuid') +const Promise = require('bluebird') +const usersapi = require('./users') + +/* ---------------------------------- PRIVATE FUNCTIONS --------------------------------- */ + +function groupApiWrapper(email, fn, req, res) { + dbapi.loadUser(email).then(function(user) { + if (!user) { + apiutil.respond(res, 404, 'Not Found (user)') + } + else { + req.user = user + fn(req, res) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to wrap "%s": ', fn.name, err.stack) + }) +} + +function getDevice(req, serial) { + return dbapi.loadDeviceBySerial(serial).then(function(device) { + if (!device) { + throw new Error(`Device not found: ${serial}`) + } + return apiutil.filterDevice(req, device) + }) +} + +function checkConflicts(id, devices, dates) { + function computeConflicts(conflicts, liteGroup, otherGroup) { + if (otherGroup.id !== liteGroup.id) { + const devices = _.intersection(liteGroup.devices, otherGroup.devices) + + if (devices.length) { + for (let liteGroupDate of liteGroup.dates) { + for (let otherGroupDate of otherGroup.dates) { + if (liteGroupDate.start < otherGroupDate.stop && + liteGroupDate.stop > otherGroupDate.start) { + conflicts.push({ + devices: devices + , date: { + start: new Date( + Math.max(liteGroupDate.start.getTime() + , otherGroupDate.start.getTime())) + , stop: new Date( + Math.min(liteGroupDate.stop.getTime() + , otherGroupDate.stop.getTime())) + } + , group: otherGroup.name + , owner: otherGroup.owner + }) + } + } + } + } + } + } + + return dbapi.getTransientGroups().then(function(groups) { + const conflicts = [] + + groups.forEach(function(otherGroup) { + computeConflicts( + conflicts + , {id: id, devices: devices, dates: dates} + , otherGroup) + }) + return conflicts + }) +} + +function checkSchedule(res, oldGroup, _class, email, repetitions, privilege, start, stop) { + if (oldGroup && oldGroup.devices.length && + (apiutil.isOriginGroup(_class) && !apiutil.isOriginGroup(oldGroup.class) || + apiutil.isOriginGroup(oldGroup.class) && !apiutil.isOriginGroup(_class))) { + return Promise.resolve(apiutil.respond(res, 403, + 'Forbidden (unauthorized class while device list is not empty)')) + } + if (apiutil.isAdminGroup(_class) && privilege === apiutil.USER) { + return Promise.resolve(apiutil.respond(res, 403, 'Forbidden (unauthorized class)')) + } + if (isNaN(start.getTime())) { + return Promise.resolve(apiutil.respond(res, 400, 'Bad Request (Invalid startTime format)')) + } + if (isNaN(stop.getTime())) { + return Promise.resolve(apiutil.respond(res, 400, 'Bad Request (Invalid stopTime format)')) + } + if (start >= stop) { + return Promise.resolve( + apiutil.respond(res, 400, 'Bad Request (Invalid life time: startTime >= stopTime)')) + } + if ((stop - start) > apiutil.CLASS_DURATION[_class]) { + return Promise.resolve(apiutil.respond(res, 400, + 'Bad Request (Invalid Life time & class combination: life time > class duration)' + )) + } + switch(_class) { + case apiutil.BOOKABLE: + case apiutil.STANDARD: + case apiutil.ONCE: + if (repetitions !== 0) { + return Promise.resolve( + apiutil.respond(res, 400, 'Bad Request (Invalid class & repetitions combination)')) + } + break + default: + if (repetitions === 0) { + return Promise.resolve( + apiutil.respond(res, 400, 'Bad Request (Invalid class & repetitions combination)')) + } + break + } + + return dbapi.loadUser(email).then(function(owner) { + if (repetitions > owner.groups.quotas.repetitions) { + return apiutil.respond(res, 400, 'Bad Request (Invalid repetitions value)') + } + return true + }) +} + +/* ---------------------------------- PUBLIC FUNCTIONS ------------------------------------- */ + +function addGroupDevices(req, res) { + const id = req.swagger.params.id.value + const serials = apiutil.getBodyParameter(req.body, 'serials') + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'device' : 'devices' + const lock = {} + let email = null + + function addGroupDevice(group, serial) { + const lock = {} + + return dbapi.lockBookableDevice(req.user.groups.subscribed, serial).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + lock.device = stats.changes[0].new_val + + return dbapi.lockGroup(lock.device.group.origin).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + lock.group = {id: lock.device.group.origin} + + return checkConflicts(id, [serial], group.dates).then(function(conflicts) { + return conflicts.length ? + Promise.reject(conflicts) : + dbapi.addGroupDevices(group, [serial]) + }) + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) + }) + .finally(function() { + lockutil.unlockDevice(lock) + }) + } + + function _addGroupDevices(lockedGroup, serials) { + let results = [] + let group = lockedGroup + + return Promise.each(serials, function(serial) { + return addGroupDevice(group, serial).then(function(result) { + results.push(result) + if (result.hasOwnProperty('id')) { + group = result + } + }) + }) + .then(function() { + results = _.without(results, 'unchanged') + if (!results.length) { + apiutil.respond(res, 200, `Unchanged (group ${target})`, {group: {}}) + } + else { + results = _.without(results, 'not found') + if (!results.length) { + apiutil.respond(res, 404, `Not Found (group ${target})`) + } + else { + apiutil.respond(res, 200, `Added (group ${target})` + , {group: apiutil.publishGroup(results[results.length - 1])}) + } + } + }) + .catch(function(err) { + if (err === 'quota is reached') { + apiutil.respond(res, 403, 'Forbidden (groups duration quota is reached)') + } + else if (Array.isArray(err)) { + apiutil.respond(res, 409, 'Conflicts Information', {conflicts: err}) + } + else if (err !== 'busy') { + throw err + } + }) + } + + lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + let group = lock.group + + if (req.user.privilege === apiutil.ADMIN && req.user.email !== group.owner.email) { + email = group.owner.email + return false + } + if (apiutil.isOriginGroup(group.class)) { + return apiutil.respond(res, 400, 'Bad Request (use admin API for bookable/standard groups)') + } + + return (function() { + if (typeof serials === 'undefined') { + return dbapi.loadBookableDevices(req.user.groups.subscribed).then(function(devices) { + const serials = [] + + devices.forEach(function(device) { + if (group.devices.indexOf(device.serial) < 0) { + serials.push(device.serial) + } + }) + return _addGroupDevices(group, serials) + }) + } + else { + return _addGroupDevices( + group + , _.difference( + _.without(serials.split(','), '') + , group.devices) + ) + } + })() + } + return false + }) + .catch(function(err) { + apiutil.internalError(res, `Failed to add group ${target}: `, err.stack) + }) + .finally(function() { + lockutil.unlockGroup(lock) + if (email) { + groupApiWrapper(email, addGroupDevices, req, res) + } + }) +} + +function addGroupDevice(req, res) { + apiutil.redirectApiWrapper('serial', addGroupDevices, req, res) +} + +function removeGroupDevices(req, res) { + const serials = apiutil.getBodyParameter(req.body, 'serials') + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'device' : 'devices' + const lock = {} + + lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + const group = lock.group + + if (apiutil.isOriginGroup(group.class)) { + return apiutil.respond(res, 400, 'Bad Request (use admin API for bookable/standard groups)') + } + let serialsToRemove = group.devices + + if (typeof serials !== 'undefined') { + serialsToRemove = _.without(serials.split(','), '') + } + if (!serialsToRemove.length) { + return apiutil.respond(res, 200, `Unchanged (group ${target})`, {group: {}}) + } + serialsToRemove = _.intersection(serialsToRemove, group.devices) + if (!serialsToRemove.length) { + return apiutil.respond(res, 404, `Not Found (group ${target})`) + } + return dbapi.removeGroupDevices(group, serialsToRemove).then(function(group) { + apiutil.respond(res, 200, `Removed (group ${target})`, {group: apiutil.publishGroup(group)}) + }) + } + return false + }) + .catch(function(err) { + apiutil.internalError(res, `Failed to remove group ${target}: `, err.stack) + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) +} + +function removeGroupDevice(req, res) { + apiutil.redirectApiWrapper('serial', removeGroupDevices, req, res) +} + +function getGroupDevice(req, res) { + const id = req.swagger.params.id.value + const serial = req.swagger.params.serial.value + + dbapi.getUserGroup(req.user.email, id).then(function(group) { + if (!group) { + apiutil.respond(res, 404, 'Not Found (group)') + } + else if (group.devices.indexOf(serial) < 0) { + apiutil.respond(res, 404, 'Not Found (device)') + } + else { + getDevice(req, serial).then(function(device) { + apiutil.respond(res, 200, 'Device Information', {device: device}) + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get group device: ', err.stack) + }) +} + +function getGroupUser(req, res) { + const id = req.swagger.params.id.value + const email = req.swagger.params.email.value + + dbapi.getUserGroup(req.user.email, id).then(function(group) { + if (!group) { + apiutil.respond(res, 404, 'Not Found (group)') + } + else if (group.users.indexOf(email) < 0) { + apiutil.respond(res, 404, 'Not Found (user)') + } + else { + usersapi.getUserByEmail(req, res) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get group user: ', err.stack) + }) +} + +function getGroupUsers(req, res) { + const id = req.swagger.params.id.value + + dbapi.getUserGroup(req.user.email, id).then(function(group) { + if (!group) { + apiutil.respond(res, 404, 'Not Found (group)') + } + else { + Promise.map(group.users, function(email) { + return usersapi.getUserInfo(req, email).then(function(user) { + return user || Promise.reject(`Group user not found: ${email}`) + }) + }) + .then(function(users) { + apiutil.respond(res, 200, 'Users Information', {users: users}) + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get group users: ', err.stack) + }) +} + +function removeGroupUsers(req, res) { + const id = req.swagger.params.id.value + const emails = apiutil.getBodyParameter(req.body, 'emails') + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'user' : 'users' + const lock = {} + + function removeGroupUser(email, group, rootGroup) { + if (group.users.indexOf(email) < 0) { + return Promise.resolve('not found') + } + if (email === rootGroup.owner.email || email === group.owner.email) { + return Promise.resolve('forbidden') + } + const lock = {} + + return dbapi.lockUser(email).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + lock.user = stats.changes[0].new_val + + return dbapi.isRemoveGroupUserAllowed(email, group) + .then(function(isAllowed) { + return isAllowed ? dbapi.removeGroupUser(id, email) : 'forbidden' + }) + }) + .finally(function() { + lockutil.unlockUser(lock) + }) + } + + lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + const group = lock.group + + return dbapi.getRootGroup().then(function(rootGroup) { + let emailsToRemove = group.users + let results = [] + + if (typeof emails !== 'undefined') { + emailsToRemove = _.without(emails.split(','), '') + } + return Promise.each(emailsToRemove, function(email) { + return removeGroupUser(email, group, rootGroup).then(function(result) { + results.push(result) + }) + }) + .then(function() { + if (!results.length) { + return apiutil.respond(res, 200, `Unchanged (group ${target})`, {group: {}}) + } + results = _.without(results, 'not found') + if (!results.length) { + return apiutil.respond(res, 404, `Not Found (group ${target})`) + } + if (!_.without(results, 'forbidden').length) { + return apiutil.respond(res, 403, `Forbidden (group ${target})`) + } + return dbapi.getGroup(id).then(function(group) { + apiutil.respond(res, 200, `Removed (group ${target})`, { + group: apiutil.publishGroup(group)}) + }) + }) + }) + .catch(function(err) { + if (err !== 'busy') { + throw err + } + }) + } + return false + }) + .catch(function(err) { + apiutil.internalError(res, `Failed to remove group ${target}: `, err.stack) + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) +} + +function removeGroupUser(req, res) { + apiutil.redirectApiWrapper('email', removeGroupUsers, req, res) +} + +function addGroupUsers(req, res) { + const id = req.swagger.params.id.value + const emails = apiutil.getBodyParameter(req.body, 'emails') + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'user' : 'users' + const lock = {} + + function addGroupUser(email) { + const lock = {} + + return dbapi.lockUser(email).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + lock.user = stats.changes[0].new_val + + return dbapi.addGroupUser(id, email) + }) + .finally(function() { + lockutil.unlockUser(lock) + }) + } + + function _addGroupUsers(emails) { + let results = [] + + return Promise.each(emails, function(email) { + return addGroupUser(email).then(function(result) { + results.push(result) + }) + }) + .then(function() { + results = _.without(results, 'unchanged') + if (!results.length) { + return apiutil.respond(res, 200, `Unchanged (group ${target})`, {group: {}}) + } + if (!_.without(results, 'not found').length) { + return apiutil.respond(res, 404, `Not Found (group ${target})`) + } + return dbapi.getGroup(id).then(function(group) { + apiutil.respond(res, 200, `Added (group ${target})`, {group: apiutil.publishGroup(group)}) + }) + }) + .catch(function(err) { + if (err !== 'busy') { + throw err + } + }) + } + + lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + if (!lockingSuccessed) { + return false + } + const group = lock.group + + return (function() { + if (typeof emails === 'undefined') { + return dbapi.getUsers().then(function(users) { + const emails = [] + + users.forEach(function(user) { + if (group.users.indexOf(user.email) < 0) { + emails.push(user.email) + } + }) + return _addGroupUsers(emails) + }) + } + else { + return _addGroupUsers( + _.difference( + _.without(emails.split(','), '') + , group.users) + ) + } + })() + }) + .catch(function(err) { + apiutil.internalError(res, `Failed to add group ${target}: `, err.stack) + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) +} + +function addGroupUser(req, res) { + apiutil.redirectApiWrapper('email', addGroupUsers, req, res) +} + +function getGroup(req, res) { + const id = req.swagger.params.id.value + const fields = req.swagger.params.fields.value + + dbapi.getUserGroup(req.user.email, id).then(function(group) { + if (!group) { + apiutil.respond(res, 404, 'Not Found (group)') + return + } + let publishedGroup = apiutil.publishGroup(group) + + if (fields) { + publishedGroup = _.pick(publishedGroup, fields.split(',')) + } + apiutil.respond(res, 200, 'Group Information', {group: publishedGroup}) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get group: ', err.stack) + }) +} + +function getGroups(req, res) { + const fields = req.swagger.params.fields.value + const owner = req.swagger.params.owner.value + let getGenericGroups + + switch(owner) { + case true: + getGenericGroups = dbapi.getOwnerGroups + break + case false: + getGenericGroups = dbapi.getOnlyUserGroups + break + default: + getGenericGroups = dbapi.getUserGroups + } + getGenericGroups(req.user.email).then(function(groups) { + return apiutil.respond(res, 200, 'Groups Information', { + groups: groups.map(function(group) { + if (fields) { + return _.pick(apiutil.publishGroup(group), fields.split(',')) + } + return apiutil.publishGroup(group) + }) + }) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get groups: ', err.stack) + }) +} + +function createGroup(req, res) { + const _class = typeof req.body.class === 'undefined' ? apiutil.ONCE : req.body.class + const repetitions = + apiutil.isOriginGroup(_class) || typeof req.body.repetitions === 'undefined' ? + 0 : + req.body.repetitions + const now = Date.now() + const start = + apiutil.isOriginGroup(_class) ? + new Date(now) : + new Date(req.body.startTime || now) + const stop = + apiutil.isOriginGroup(_class) ? + new Date(now + apiutil.ONE_YEAR) : + new Date(req.body.stopTime || now + apiutil.ONE_HOUR) + + checkSchedule(res, null, _class, req.user.email, repetitions, req.user.privilege, + start, stop).then(function(checkingSuccessed) { + if (!checkingSuccessed) { + return + } + const name = + typeof req.body.name === 'undefined' ? + 'New_' + util.format('%s', uuid.v4()).replace(/-/g, '') : + req.body.name + const state = + apiutil.isOriginGroup(_class) || typeof req.body.state === 'undefined' ? + apiutil.READY : + req.body.state + const isActive = state === apiutil.READY && apiutil.isOriginGroup(_class) + const duration = 0 + const dates = apiutil.computeGroupDates({start: start, stop: stop}, _class, repetitions) + + dbapi.createUserGroup({ + name: name + , owner: { + email: req.user.email + , name: req.user.name + } + , privilege: req.user.privilege + , class: _class + , repetitions: repetitions + , isActive: isActive + , dates: dates + , duration: duration + , state: state + }) + .then(function(group) { + if (group) { + apiutil.respond(res, 201, 'Created', {group: apiutil.publishGroup(group)}) + } + else { + apiutil.respond(res, 403, 'Forbidden (groups number quota is reached)') + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to create group: ', err.stack) + }) + }) +} + +function deleteGroups(req, res) { + const ids = apiutil.getBodyParameter(req.body, 'ids') + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'group' : 'groups' + + function removeGroup(id) { + const lock = {} + + return dbapi.lockGroupByOwner(req.user.email, id).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + const group = lock.group = stats.changes[0].new_val + + if (group.privilege === apiutil.ROOT) { + return 'forbidden' + } + if (group.class === apiutil.BOOKABLE) { + return Promise.each(group.devices, function(serial) { + return dbapi.isDeviceBooked(serial) + .then(function(isBooked) { + return isBooked ? Promise.reject('booked') : true + }) + }) + .then(function() { + return dbapi.deleteUserGroup(id) + }) + .catch(function(err) { + if (err !== 'booked') { + throw err + } + return 'forbidden' + }) + } + else { + return dbapi.deleteUserGroup(id) + } + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) + } + + function removeGroups(ids) { + let results = [] + + return Promise.each(ids, function(id) { + return removeGroup(id).then(function(result) { + results.push(result) + }) + }) + .then(function() { + if (!results.length) { + return apiutil.respond(res, 200, `Unchanged (${target})`) + } + results = _.without(results, 'not found') + if (!results.length) { + return apiutil.respond(res, 404, `Not Found (${target})`) + } + results = _.without(results, 'forbidden') + if (!results.length) { + return apiutil.respond(res, 403, `Forbidden (${target})`) + } + return apiutil.respond(res, 200, `Deleted (${target})`) + }) + .catch(function(err) { + if (err !== 'busy') { + throw err + } + }) + } + + (function() { + if (typeof ids === 'undefined') { + return dbapi.getOwnerGroups(req.user.email).then(function(groups) { + const ids = [] + + groups.forEach(function(group) { + if (group.privilege !== apiutil.ROOT) { + ids.push(group.id) + } + }) + return removeGroups(ids) + }) + } + else { + return removeGroups(_.without(ids.split(','), '')) + } + })() + .catch(function(err) { + apiutil.internalError(res, `Failed to delete ${target}: `, err.stack) + }) +} + +function deleteGroup(req, res) { + apiutil.redirectApiWrapper('id', deleteGroups, req, res) +} + +function updateGroup(req, res) { + const id = req.swagger.params.id.value + const lock = {} + + function updateUserGroup(group, data) { + return dbapi.updateUserGroup(group, data) + .then(function(group) { + if (group) { + apiutil.respond(res, 200, 'Updated (group)', {group: apiutil.publishGroup(group)}) + } + else { + apiutil.respond(res, 403, 'Forbidden (groups duration quota is reached)') + } + }) + } + + lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + if (!lockingSuccessed) { + return false + } + const group = lock.group + const _class = typeof req.body.class === 'undefined' ? group.class : req.body.class + const name = typeof req.body.name === 'undefined' ? group.name : req.body.name + const repetitions = + typeof req.body.repetitions === 'undefined' ? + group.repetitions : + req.body.repetitions + const start = new Date(req.body.startTime || group.dates[0].start) + const stop = new Date(req.body.stopTime || group.dates[0].stop) + let state, isActive + + if (apiutil.isOriginGroup(_class)) { + state = apiutil.READY + isActive = true + } + else { + state = typeof req.body.state === 'undefined' ? apiutil.PENDING : req.body.state + isActive = false + } + + if (group.state === apiutil.READY && state === apiutil.PENDING) { + return apiutil.respond(res, 403, 'Forbidden (group is ready)') + } + + return checkSchedule(res, group, _class, group.owner.email, repetitions, group.privilege, + start, stop).then(function(checkingSuccessed) { + if (!checkingSuccessed) { + return false + } + if (name === group.name && + start.toISOString() === group.dates[0].start.toISOString() && + stop.toISOString() === group.dates[0].stop.toISOString() && + state === group.state && + _class === group.class && + repetitions === group.repetitions) { + return apiutil.respond(res, 200, 'Unchanged (group)', {group: {}}) + } + const duration = group.devices.length * (stop - start) * (repetitions + 1) + const dates = apiutil.computeGroupDates({start: start, stop: stop}, _class, repetitions) + + if (start < group.dates[0].start || + stop > group.dates[0].stop || + repetitions > group.repetitions || + _class !== group.class) { + return checkConflicts(id, group.devices, dates) + .then(function(conflicts) { + if (!conflicts.length) { + return updateUserGroup(group, { + name: name + , state: state + , class: _class + , isActive: isActive + , repetitions: repetitions + , dates: dates + , duration: duration + }) + } + return apiutil.respond(res, 409, 'Conflicts Information', {conflicts: conflicts}) + }) + } + return updateUserGroup(group, { + name: name + , state: state + , class: _class + , isActive: isActive + , repetitions: repetitions + , dates: dates + , duration: duration + }) + }) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to update group: ', err.stack) + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) +} + +function getGroupDevices(req, res) { + const id = req.swagger.params.id.value + const bookable = req.swagger.params.bookable.value + + dbapi.getUserGroup(req.user.email, id).then(function(group) { + if (!group) { + apiutil.respond(res, 404, 'Not Found (group)') + return + } + if (bookable) { + if (apiutil.isOriginGroup(group.class)) { + apiutil.respond(res, 400, 'Bad Request (group is not transient)') + return + } + if (req.user.privilege === apiutil.ADMIN && req.user.email !== group.owner.email) { + groupApiWrapper(group.owner.email, getGroupDevices, req, res) + return + } + dbapi.loadBookableDevices(req.user.groups.subscribed).then(function(devices) { + Promise.map(devices, function(device) { + return device.serial + }) + .then(function(serials) { + return checkConflicts(group.id, serials, group.dates) + .then(function(conflicts) { + let bookableSerials = serials + + conflicts.forEach(function(conflict) { + bookableSerials = _.difference(bookableSerials, conflict.devices) + }) + return bookableSerials + }) + }) + .then(function(bookableSerials) { + const deviceList = [] + + devices.forEach(function(device) { + if (bookableSerials.indexOf(device.serial) > -1) { + deviceList.push(apiutil.filterDevice(req, device)) + } + }) + apiutil.respond(res, 200, 'Devices Information', {devices: deviceList}) + }) + }) + } + else { + Promise.map(group.devices, function(serial) { + return getDevice(req, serial) + }) + .then(function(devices) { + apiutil.respond(res, 200, 'Devices Information', {devices: devices}) + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get group devices: ', err.stack) + }) +} + +module.exports = { + createGroup: createGroup + , updateGroup: updateGroup + , deleteGroup: deleteGroup + , deleteGroups: deleteGroups + , getGroup: getGroup + , getGroups: getGroups + , getGroupUser: getGroupUser + , getGroupUsers: getGroupUsers + , addGroupUser: addGroupUser + , addGroupUsers: addGroupUsers + , removeGroupUser: removeGroupUser + , removeGroupUsers: removeGroupUsers + , getGroupDevice: getGroupDevice + , getGroupDevices: getGroupDevices + , addGroupDevice: addGroupDevice + , addGroupDevices: addGroupDevices + , removeGroupDevice: removeGroupDevice + , removeGroupDevices: removeGroupDevices +} diff --git a/lib/units/api/controllers/user.js b/lib/units/api/controllers/user.js index e2fc3e5c..b8ccc3f7 100644 --- a/lib/units/api/controllers/user.js +++ b/lib/units/api/controllers/user.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var util = require('util') var _ = require('lodash') @@ -12,20 +16,121 @@ var wire = require('../../../wire') var wireutil = require('../../../wire/util') var wirerouter = require('../../../wire/router') +const apiutil = require('../../../util/apiutil') +const jwtutil = require('../../../util/jwtutil') + var log = logger.createLogger('api:controllers:user') -module.exports = { - getUser: getUser -, getUserDevices: getUserDevices -, addUserDevice: addUserDevice -, getUserDeviceBySerial: getUserDeviceBySerial -, deleteUserDeviceBySerial: deleteUserDeviceBySerial -, remoteConnectUserDeviceBySerial: remoteConnectUserDeviceBySerial -, remoteDisconnectUserDeviceBySerial: remoteDisconnectUserDeviceBySerial -, getUserAccessTokens: getUserAccessTokens +function getAccessToken(req, res) { + const id = req.swagger.params.id.value + + dbapi.loadAccessToken(id).then(function(token) { + if (!token || token.email !== req.user.email) { + apiutil.respond(res, 404, 'Not Found (access token)') + } + else { + apiutil.respond(res, 200, 'Access Token Information', { + token: apiutil.publishAccessToken(token) + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to delete access token "%s": ', id, err.stack) + }) +} + +function getAccessTokens(req, res) { + dbapi.loadAccessTokens(req.user.email).then(function(cursor) { + Promise.promisify(cursor.toArray, cursor)().then(function(tokens) { + const tokenList = [] + + tokens.forEach(function(token) { + tokenList.push(apiutil.publishAccessToken(token)) + }) + apiutil.respond(res, 200, 'Access Tokens Information', {tokens: tokenList}) + }) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get access tokens: ', err.stack) + }) +} + +function createAccessToken(req, res) { + const title = req.swagger.params.title.value + const jwt = jwtutil.encode({ + payload: { + email: req.user.email + , name: req.user.name + } + , secret: req.options.secret + }) + const id = util.format('%s-%s', uuid.v4(), uuid.v4()).replace(/-/g, '') + + dbapi.saveUserAccessToken(req.user.email, { + title: title + , id: id + , jwt: jwt + }) + .then(function(stats) { + req.options.pushdev.send([ + req.user.group + , wireutil.envelope(new wire.UpdateAccessTokenMessage()) + ]) + apiutil.respond(res, 201, 'Created (access token)', + {token: apiutil.publishAccessToken(stats.changes[0].new_val)}) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to create access token "%s": ', title, err.stack) + }) +} + +function deleteAccessTokens(req, res) { + dbapi.removeUserAccessTokens(req.user.email).then(function(stats) { + if (!stats.deleted) { + apiutil.respond(res, 200, 'Unchanged (access tokens)') + } + else { + req.options.pushdev.send([ + req.user.group + , wireutil.envelope(new wire.UpdateAccessTokenMessage()) + ]) + apiutil.respond(res, 200, 'Deleted (access tokens)') + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to delete access tokens: ', err.stack) + }) +} + +function deleteAccessToken(req, res) { + const id = req.swagger.params.id.value + + dbapi.loadAccessToken(id).then(function(token) { + if (!token || token.email !== req.user.email) { + apiutil.respond(res, 404, 'Not Found (access token)') + } + else { + dbapi.removeAccessToken(id).then(function(stats) { + if (!stats.deleted) { + apiutil.respond(res, 404, 'Not Found (access token)') + } + else { + req.options.pushdev.send([ + req.user.group + , wireutil.envelope(new wire.UpdateAccessTokenMessage()) + ]) + apiutil.respond(res, 200, 'Deleted (access token)') + } + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to delete access token "%s": ', id, err.stack) + }) } function getUser(req, res) { + // delete req.user.groups.lock res.json({ success: true , user: req.user @@ -52,6 +157,7 @@ function getUserDevices(req, res) { res.json({ success: true + , description: 'Controlled devices information' , devices: deviceList }) }) @@ -60,6 +166,7 @@ function getUserDevices(req, res) { log.error('Failed to load device list: ', err.stack) res.status(500).json({ success: false + , description: 'Internal Server Error' }) }) } @@ -68,113 +175,121 @@ function getUserDeviceBySerial(req, res) { var serial = req.swagger.params.serial.value var fields = req.swagger.params.fields.value - dbapi.loadDevice(serial) - .then(function(device) { - if (!device) { - return res.status(404).json({ - success: false - , description: 'Device not found' + dbapi.loadDevice(req.user.groups.subscribed, serial) + .then(function(cursor) { + cursor.next(function(err, device) { + if (err) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } + + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is not owned by you' + }) + } + + var responseDevice = device + if (fields) { + responseDevice = _.pick(device, fields.split(',')) + } + + res.json({ + success: true + , description: 'Controlled device information' + , device: responseDevice }) - } - - datautil.normalize(device, req.user) - if (!deviceutil.isOwnedByUser(device, req.user)) { - return res.status(403).json({ - success: false - , description: 'Device is not owned by you' - }) - } - - var responseDevice = device - if (fields) { - responseDevice = _.pick(device, fields.split(',')) - } - - res.json({ - success: true - , device: responseDevice }) }) .catch(function(err) { log.error('Failed to load device "%s": ', req.params.serial, err.stack) res.status(500).json({ success: false + , description: 'Internal Server Error' }) }) } function addUserDevice(req, res) { - var serial = req.body.serial - var timeout = req.body.timeout || null + var serial = req.hasOwnProperty('body') ? req.body.serial : req.swagger.params.serial.value + var timeout = req.hasOwnProperty('body') ? req.body.timeout || + null : req.swagger.params.timeout.value || null - dbapi.loadDevice(serial) - .then(function(device) { - if (!device) { - return res.status(404).json({ - success: false - , description: 'Device not found' - }) - } - - datautil.normalize(device, req.user) - if (!deviceutil.isAddable(device, req.user)) { - return res.status(403).json({ - success: false - , description: 'Device is being used or not available' - }) - } - - // Timer will be called if no JoinGroupMessage is received till 5 seconds - var responseTimer = setTimeout(function() { - req.options.channelRouter.removeListener(wireutil.global, messageListener) - return res.status(504).json({ + dbapi.loadDevice(req.user.groups.subscribed, serial) + .then(function(cursor) { + cursor.next(function(err, device) { + if (err) { + return res.status(404).json({ success: false - , description: 'Device is not responding' - }) - }, 5000) + , description: 'Device not found' + }) + } - var messageListener = wirerouter() - .on(wire.JoinGroupMessage, function(channel, message) { - if (message.serial === serial && message.owner.email === req.user.email) { - clearTimeout(responseTimer) - req.options.channelRouter.removeListener(wireutil.global, messageListener) + datautil.normalize(device, req.user) + if (!deviceutil.isAddable(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is being used or not available' + }) + } - return res.json({ - success: true - , description: 'Device successfully added' - }) - } - }) - .handler() + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var responseTimer = setTimeout(function() { + req.options.channelRouter.removeListener(wireutil.global, messageListener) + return res.status(504).json({ + success: false + , description: 'Device is not responding' + }) + }, 5000) - req.options.channelRouter.on(wireutil.global, messageListener) - var usage = 'automation' + var messageListener = wirerouter() + .on(wire.JoinGroupMessage, function(channel, message) { + if (message.serial === serial && message.owner.email === req.user.email) { + clearTimeout(responseTimer) + req.options.channelRouter.removeListener(wireutil.global, messageListener) - req.options.push.send([ - device.channel - , wireutil.envelope( - new wire.GroupMessage( - new wire.OwnerMessage( - req.user.email - , req.user.name - , req.user.group - ) - , timeout - , wireutil.toDeviceRequirements({ - serial: { - value: serial - , match: 'exact' + return res.json({ + success: true + , description: 'Device successfully added' + }) } }) - , usage + .handler() + + req.options.channelRouter.on(wireutil.global, messageListener) + var usage = 'automation' + + req.options.push.send([ + device.channel + , wireutil.envelope( + new wire.GroupMessage( + new wire.OwnerMessage( + req.user.email + , req.user.name + , req.user.group + ) + , timeout + , wireutil.toDeviceRequirements({ + serial: { + value: serial + , match: 'exact' + } + }) + , usage + ) ) - ) - ]) + ]) + }) }) .catch(function(err) { log.error('Failed to load device "%s": ', req.params.serial, err.stack) res.status(500).json({ success: false + , description: 'Internal Server Error' }) }) } @@ -182,66 +297,70 @@ function addUserDevice(req, res) { function deleteUserDeviceBySerial(req, res) { var serial = req.swagger.params.serial.value - dbapi.loadDevice(serial) - .then(function(device) { - if (!device) { - return res.status(404).json({ - success: false - , description: 'Device not found' - }) - } - - datautil.normalize(device, req.user) - if (!deviceutil.isOwnedByUser(device, req.user)) { - return res.status(403).json({ - success: false - , description: 'You cannot release this device. Not owned by you' - }) - } - - // Timer will be called if no JoinGroupMessage is received till 5 seconds - var responseTimer = setTimeout(function() { - req.options.channelRouter.removeListener(wireutil.global, messageListener) - return res.status(504).json({ + dbapi.loadDevice(req.user.groups.subscribed, serial) + .then(function(cursor) { + cursor.next(function(err, device) { + if (err) { + return res.status(404).json({ success: false - , description: 'Device is not responding' - }) - }, 5000) + , description: 'Device not found' + }) + } - var messageListener = wirerouter() - .on(wire.LeaveGroupMessage, function(channel, message) { - if (message.serial === serial && message.owner.email === req.user.email) { - clearTimeout(responseTimer) - req.options.channelRouter.removeListener(wireutil.global, messageListener) + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'You cannot release this device. Not owned by you' + }) + } - return res.json({ - success: true - , description: 'Device successfully removed' - }) - } - }) - .handler() + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var responseTimer = setTimeout(function() { + req.options.channelRouter.removeListener(wireutil.global, messageListener) + return res.status(504).json({ + success: false + , description: 'Device is not responding' + }) + }, 5000) - req.options.channelRouter.on(wireutil.global, messageListener) + var messageListener = wirerouter() + .on(wire.LeaveGroupMessage, function(channel, message) { + if (message.serial === serial && + (message.owner.email === req.user.email || req.user.privilege === 'admin')) { + clearTimeout(responseTimer) + req.options.channelRouter.removeListener(wireutil.global, messageListener) - req.options.push.send([ - device.channel - , wireutil.envelope( - new wire.UngroupMessage( - wireutil.toDeviceRequirements({ - serial: { - value: serial - , match: 'exact' - } - }) + return res.json({ + success: true + , description: 'Device successfully removed' + }) + } + }) + .handler() + + req.options.channelRouter.on(wireutil.global, messageListener) + + req.options.push.send([ + device.channel + , wireutil.envelope( + new wire.UngroupMessage( + wireutil.toDeviceRequirements({ + serial: { + value: serial + , match: 'exact' + } + }) + ) ) - ) - ]) + ]) + }) }) .catch(function(err) { log.error('Failed to load device "%s": ', req.params.serial, err.stack) res.status(500).json({ success: false + , description: 'Internal Server Error' }) }) } @@ -249,65 +368,68 @@ function deleteUserDeviceBySerial(req, res) { function remoteConnectUserDeviceBySerial(req, res) { var serial = req.swagger.params.serial.value - dbapi.loadDevice(serial) - .then(function(device) { - if (!device) { - return res.status(404).json({ - success: false - , description: 'Device not found' - }) - } - - datautil.normalize(device, req.user) - if (!deviceutil.isOwnedByUser(device, req.user)) { - return res.status(403).json({ - success: false - , description: 'Device is not owned by you or is not available' - }) - } - - var responseChannel = 'txn_' + uuid.v4() - req.options.sub.subscribe(responseChannel) - - // Timer will be called if no JoinGroupMessage is received till 5 seconds - var timer = setTimeout(function() { - req.options.channelRouter.removeListener(responseChannel, messageListener) - req.options.sub.unsubscribe(responseChannel) - return res.status(504).json({ + dbapi.loadDevice(req.user.groups.subscribed, serial) + .then(function(cursor) { + cursor.next(function(err, device) { + if (err) { + return res.status(404).json({ success: false - , description: 'Device is not responding' - }) - }, 5000) + , description: 'Device not found' + }) + } - var messageListener = wirerouter() - .on(wire.ConnectStartedMessage, function(channel, message) { - if (message.serial === serial) { - clearTimeout(timer) - req.options.sub.unsubscribe(responseChannel) - req.options.channelRouter.removeListener(responseChannel, messageListener) + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is not owned by you or is not available' + }) + } - return res.json({ - success: true - , remoteConnectUrl: message.url - }) - } - }) - .handler() + var responseChannel = 'txn_' + uuid.v4() + req.options.sub.subscribe(responseChannel) - req.options.channelRouter.on(responseChannel, messageListener) + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var timer = setTimeout(function() { + req.options.channelRouter.removeListener(responseChannel, messageListener) + req.options.sub.unsubscribe(responseChannel) + return res.status(504).json({ + success: false + , description: 'Device is not responding' + }) + }, 5000) - req.options.push.send([ - device.channel - , wireutil.transaction( - responseChannel - , new wire.ConnectStartMessage() - ) - ]) + var messageListener = wirerouter() + .on(wire.ConnectStartedMessage, function(channel, message) { + if (message.serial === serial) { + clearTimeout(timer) + req.options.sub.unsubscribe(responseChannel) + req.options.channelRouter.removeListener(responseChannel, messageListener) + return res.json({ + success: true + , description: 'Remote connection is enabled' + , remoteConnectUrl: message.url + }) + } + }) + .handler() + + req.options.channelRouter.on(responseChannel, messageListener) + + req.options.push.send([ + device.channel + , wireutil.transaction( + responseChannel + , new wire.ConnectStartMessage() + ) + ]) + }) }) .catch(function(err) { log.error('Failed to load device "%s": ', req.params.serial, err.stack) res.status(500).json({ success: false + , description: 'Internal Server Error' }) }) } @@ -315,65 +437,67 @@ function remoteConnectUserDeviceBySerial(req, res) { function remoteDisconnectUserDeviceBySerial(req, res) { var serial = req.swagger.params.serial.value - dbapi.loadDevice(serial) - .then(function(device) { - if (!device) { - return res.status(404).json({ - success: false - , description: 'Device not found' - }) - } + dbapi.loadDevice(req.user.groups.subscribed, serial) + .then(function(cursor) { + cursor.next(function(err, device) { + if (err) { + return res.status(404).json({ + success: false + , description: 'Device not found' + }) + } - datautil.normalize(device, req.user) - if (!deviceutil.isOwnedByUser(device, req.user)) { - return res.status(403).json({ - success: false - , description: 'Device is not owned by you or is not available' - }) - } + datautil.normalize(device, req.user) + if (!deviceutil.isOwnedByUser(device, req.user)) { + return res.status(403).json({ + success: false + , description: 'Device is not owned by you or is not available' + }) + } - var responseChannel = 'txn_' + uuid.v4() - req.options.sub.subscribe(responseChannel) + var responseChannel = 'txn_' + uuid.v4() + req.options.sub.subscribe(responseChannel) - // Timer will be called if no JoinGroupMessage is received till 5 seconds - var timer = setTimeout(function() { - req.options.channelRouter.removeListener(responseChannel, messageListener) - req.options.sub.unsubscribe(responseChannel) - return res.status(504).json({ + // Timer will be called if no JoinGroupMessage is received till 5 seconds + var timer = setTimeout(function() { + req.options.channelRouter.removeListener(responseChannel, messageListener) + req.options.sub.unsubscribe(responseChannel) + return res.status(504).json({ success: false , description: 'Device is not responding' - }) - }, 5000) + }) + }, 5000) - var messageListener = wirerouter() - .on(wire.ConnectStoppedMessage, function(channel, message) { - if (message.serial === serial) { - clearTimeout(timer) - req.options.sub.unsubscribe(responseChannel) - req.options.channelRouter.removeListener(responseChannel, messageListener) + var messageListener = wirerouter() + .on(wire.ConnectStoppedMessage, function(channel, message) { + if (message.serial === serial) { + clearTimeout(timer) + req.options.sub.unsubscribe(responseChannel) + req.options.channelRouter.removeListener(responseChannel, messageListener) + return res.json({ + success: true + , description: 'Device remote disconnected successfully' + }) + } + }) + .handler() - return res.json({ - success: true - , description: 'Device remote disconnected successfully' - }) - } - }) - .handler() + req.options.channelRouter.on(responseChannel, messageListener) - req.options.channelRouter.on(responseChannel, messageListener) - - req.options.push.send([ - device.channel - , wireutil.transaction( - responseChannel - , new wire.ConnectStopMessage() - ) - ]) + req.options.push.send([ + device.channel + , wireutil.transaction( + responseChannel + , new wire.ConnectStopMessage() + ) + ]) + }) }) .catch(function(err) { log.error('Failed to load device "%s": ', req.params.serial, err.stack) res.status(500).json({ success: false + , description: 'Internal Server Error' }) }) } @@ -400,3 +524,20 @@ function getUserAccessTokens(req, res) { }) }) } + +module.exports = { + getUser: getUser +, getUserDevices: getUserDevices +, addUserDevice: addUserDevice +, addUserDeviceV2: addUserDevice +, getUserDeviceBySerial: getUserDeviceBySerial +, deleteUserDeviceBySerial: deleteUserDeviceBySerial +, remoteConnectUserDeviceBySerial: remoteConnectUserDeviceBySerial +, remoteDisconnectUserDeviceBySerial: remoteDisconnectUserDeviceBySerial +, getUserAccessTokens: getUserAccessTokens +, getAccessTokens: getAccessTokens +, getAccessToken: getAccessToken +, createAccessToken: createAccessToken +, deleteAccessToken: deleteAccessToken +, deleteAccessTokens: deleteAccessTokens +} diff --git a/lib/units/api/controllers/users.js b/lib/units/api/controllers/users.js new file mode 100644 index 00000000..7820710d --- /dev/null +++ b/lib/units/api/controllers/users.js @@ -0,0 +1,398 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const dbapi = require('../../../db/api') +const _ = require('lodash') +const apiutil = require('../../../util/apiutil') +const lockutil = require('../../../util/lockutil') +const Promise = require('bluebird') +const wire = require('../../../wire') +const wireutil = require('../../../wire/util') +const userapi = require('./user') + +/* --------------------------------- PRIVATE FUNCTIONS --------------------------------------- */ + +function userApiWrapper(fn, req, res) { + const email = req.swagger.params.email.value + + dbapi.loadUser(email).then(function(user) { + if (!user) { + apiutil.respond(res, 404, 'Not Found (user)') + } + else { + req.user = user + fn(req, res) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to wrap "%s": ', fn.name, err.stack) + }) +} + +function getPublishedUser(user, userEmail, adminEmail, fields) { + let publishedUser = apiutil.publishUser(user) + if (userEmail !== adminEmail) { + publishedUser = _.pick(user, 'email', 'name', 'privilege') + } + if (fields) { + publishedUser = _.pick(publishedUser, fields.split(',')) + } + return publishedUser +} + +function removeUser(email, req, res) { + const groupOwnerState = req.swagger.params.groupOwner.value + const anyGroupOwnerState = typeof groupOwnerState === 'undefined' + const lock = {} + + function removeGroupUser(owner, id) { + const lock = {} + + return dbapi.lockGroupByOwner(owner, id).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + lock.group = stats.changes[0].new_val + + return owner === email ? + dbapi.deleteUserGroup(id) : + dbapi.removeGroupUser(id, email) + }) + .finally(function() { + lockutil.unlockGroup(lock) + }) + } + + function deleteUserInDatabase(channel) { + return dbapi.removeUserAccessTokens(email).then(function() { + return dbapi.deleteUser(email).then(function() { + req.options.pushdev.send([ + channel + , wireutil.envelope(new wire.DeleteUserMessage( + email + )) + ]) + return 'deleted' + }) + }) + } + + function computeUserGroupOwnership(groups) { + if (anyGroupOwnerState) { + return Promise.resolve(true) + } + return Promise.map(groups, function(group) { + if (!groupOwnerState && group.owner.email === email) { + return Promise.reject('filtered') + } + return !groupOwnerState || group.owner.email === email + }) + .then(function(results) { + return _.without(results, false).length > 0 + }) + .catch(function(err) { + if (err === 'filtered') { + return false + } + throw err + }) + } + + if (req.user.email === email) { + return Promise.resolve('forbidden') + } + return dbapi.lockUser(email).then(function(stats) { + if (!stats.replaced) { + return apiutil.lightComputeStats(res, stats) + } + const user = lock.user = stats.changes[0].new_val + + return dbapi.getGroupsByUser(user.email).then(function(groups) { + return computeUserGroupOwnership(groups).then(function(doContinue) { + if (!doContinue) { + return 'unchanged' + } + return Promise.each(groups, function(group) { + return removeGroupUser(group.owner.email, group.id) + }) + .then(function() { + return deleteUserInDatabase(user.group) + }) + }) + }) + }) + .finally(function() { + lockutil.unlockUser(lock) + }) +} + +/* --------------------------------- PUBLIC FUNCTIONS --------------------------------------- */ + +function getUserInfo(req, email) { + const fields = req.swagger.params.fields.value + + return dbapi.loadUser(email).then(function(user) { + if (user) { + return dbapi.getRootGroup().then(function(group) { + return getPublishedUser(user, req.user.email, group.owner.email, fields) + }) + } + return false + }) +} + +function updateUserGroupsQuotas(req, res) { + const email = req.swagger.params.email.value + const duration = + typeof req.swagger.params.duration.value !== 'undefined' ? + req.swagger.params.duration.value : + null + const number = + typeof req.swagger.params.number.value !== 'undefined' ? + req.swagger.params.number.value : + null + const repetitions = + typeof req.swagger.params.repetitions.value !== 'undefined' ? + req.swagger.params.repetitions.value : + null + const lock = {} + + lockutil.lockUser(email, res, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + return dbapi.updateUserGroupsQuotas(email, duration, number, repetitions) + .then(function(stats) { + if (stats.replaced) { + return apiutil.respond(res, 200, 'Updated (user quotas)', { + user: apiutil.publishUser(stats.changes[0].new_val) + }) + } + if ((duration === null || duration === lock.user.groups.quotas.allocated.duration) && + (number === null || number === lock.user.groups.quotas.allocated.number) && + (repetitions === null || repetitions === lock.user.groups.quotas.repetitions) + ) { + return apiutil.respond(res, 200, 'Unchanged (user quotas)', {user: {}}) + } + return apiutil.respond( + res + , 400 + , 'Bad Request (quotas must be >= actual consumed resources)') + }) + } + return false + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to update user groups quotas: ', err.stack) + }) + .finally(function() { + lockutil.unlockUser(lock) + }) +} + +function updateDefaultUserGroupsQuotas(req, res) { + const duration = + typeof req.swagger.params.duration.value !== 'undefined' ? + req.swagger.params.duration.value : + null + const number = + typeof req.swagger.params.number.value !== 'undefined' ? + req.swagger.params.number.value : + null + const repetitions = + typeof req.swagger.params.repetitions.value !== 'undefined' ? + req.swagger.params.repetitions.value : + null + const lock = {} + + lockutil.lockUser(req.user.email, res, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + return dbapi.updateDefaultUserGroupsQuotas(req.user.email, duration, number, repetitions) + .then(function(stats) { + if (stats.replaced) { + return apiutil.respond(res, 200, 'Updated (user default quotas)', { + user: apiutil.publishUser(stats.changes[0].new_val) + }) + } + return apiutil.respond(res, 200, 'Unchanged (user default quotas)', {user: {}}) + }) + } + return false + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to update default user groups quotas: ', err.stack) + }) + .finally(function() { + lockutil.unlockUser(lock) + }) +} + +function getUserByEmail(req, res) { + const email = req.swagger.params.email.value + + getUserInfo(req, email).then(function(user) { + if (user) { + apiutil.respond(res, 200, 'User Information', {user: user}) + } + else { + apiutil.respond(res, 404, 'Not Found (user)') + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get user: ', err.stack) + }) +} + +function getUsers(req, res) { + const fields = req.swagger.params.fields.value + + dbapi.getUsers().then(function(users) { + return dbapi.getRootGroup().then(function(group) { + apiutil.respond(res, 200, 'Users Information', { + users: users.map(function(user) { + return getPublishedUser(user, req.user.email, group.owner.email, fields) + }) + }) + }) + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to get users: ', err.stack) + }) +} + +function createUser(req, res) { + const email = req.swagger.params.email.value + const name = req.swagger.params.name.value + + dbapi.createUser(email, name, req.user.ip).then(function(stats) { + if (!stats.inserted) { + apiutil.respond(res, 403, 'Forbidden (user already exists)') + } + else { + apiutil.respond(res, 201, 'Created (user)', { + user: apiutil.publishUser(stats.changes[0].new_val) + }) + } + }) + .catch(function(err) { + apiutil.internalError(res, 'Failed to create user: ', err.stack) + }) +} + +function deleteUsers(req, res) { + const emails = apiutil.getBodyParameter(req.body, 'emails') + const target = apiutil.getQueryParameter(req.swagger.params.redirected) ? 'user' : 'users' + + function removeUsers(emails) { + let results = [] + + return Promise.each(emails, function(email) { + return removeUser(email, req, res).then(function(result) { + results.push(result) + }) + }) + .then(function() { + results = _.without(results, 'unchanged') + if (!results.length) { + return apiutil.respond(res, 200, `Unchanged (${target})`) + } + results = _.without(results, 'not found') + if (!results.length) { + return apiutil.respond(res, 404, `Not Found (${target})`) + } + results = _.without(results, 'forbidden') + if (!results.length) { + apiutil.respond(res, 403, `Forbidden (${target})`) + } + return apiutil.respond(res, 200, `Deleted (${target})`) + }) + .catch(function(err) { + if (err !== 'busy') { + throw err + } + }) + } + + (function() { + if (typeof emails === 'undefined') { + return dbapi.getEmails().then(function(emails) { + return removeUsers(emails) + }) + } + else { + return removeUsers(_.without(emails.split(','), '')) + } + })() + .catch(function(err) { + apiutil.internalError(res, 'Failed to delete ${target}: ', err.stack) + }) +} + +function deleteUser(req, res) { + apiutil.redirectApiWrapper('email', deleteUsers, req, res) +} + +function createUserAccessToken(req, res) { + userApiWrapper(userapi.createAccessToken, req, res) +} + +function deleteUserAccessToken(req, res) { + userApiWrapper(userapi.deleteAccessToken, req, res) +} + +function deleteUserAccessTokens(req, res) { + userApiWrapper(userapi.deleteAccessTokens, req, res) +} + +function getUserAccessToken(req, res) { + userApiWrapper(userapi.getAccessToken, req, res) +} + +function getUserAccessTokens(req, res) { + userApiWrapper(userapi.getAccessTokens, req, res) +} + +function getUserDevices(req, res) { + userApiWrapper(userapi.getUserDevices, req, res) +} + +function getUserDevice(req, res) { + userApiWrapper(userapi.getUserDeviceBySerial, req, res) +} + +function addUserDevice(req, res) { + userApiWrapper(userapi.addUserDevice, req, res) +} + +function deleteUserDevice(req, res) { + userApiWrapper(userapi.deleteUserDeviceBySerial, req, res) +} + +function remoteConnectUserDevice(req, res) { + userApiWrapper(userapi.remoteConnectUserDeviceBySerial, req, res) +} + +function remoteDisconnectUserDevice(req, res) { + userApiWrapper(userapi.remoteDisconnectUserDeviceBySerial, req, res) +} + +module.exports = { + updateUserGroupsQuotas: updateUserGroupsQuotas + , updateDefaultUserGroupsQuotas: updateDefaultUserGroupsQuotas + , getUsers: getUsers + , getUserByEmail: getUserByEmail + , getUserInfo: getUserInfo + , createUser: createUser + , deleteUser: deleteUser + , deleteUsers: deleteUsers + , createUserAccessToken: createUserAccessToken + , deleteUserAccessToken: deleteUserAccessToken + , deleteUserAccessTokens: deleteUserAccessTokens + , getUserAccessTokensV2: getUserAccessTokens + , getUserAccessToken: getUserAccessToken + , getUserDevicesV2: getUserDevices + , getUserDevice: getUserDevice + , addUserDeviceV3: addUserDevice + , deleteUserDevice: deleteUserDevice + , remoteConnectUserDevice: remoteConnectUserDevice + , remoteDisconnectUserDevice: remoteDisconnectUserDevice +} diff --git a/lib/units/api/helpers/securityHandlers.js b/lib/units/api/helpers/securityHandlers.js index 99eedd1c..600be491 100644 --- a/lib/units/api/helpers/securityHandlers.js +++ b/lib/units/api/helpers/securityHandlers.js @@ -1,7 +1,12 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var dbapi = require('../../../db/api') var jwtutil = require('../../../util/jwtutil') var urlutil = require('../../../util/urlutil') var logger = require('../../../util/logger') +const apiutil = require('../../../util/apiutil') var log = logger.createLogger('api:helpers:securityHandlers') @@ -47,17 +52,27 @@ function accessTokenAuth(req, res, next) { if (!data) { return res.status(500).json({ success: false + , description: 'Internal Server Error' }) } + dbapi.loadUser(data.email) .then(function(user) { if (user) { + if (user.privilege === apiutil.USER && + req.swagger.operation.definition.tags.indexOf('admin') > -1) { + return res.status(403).json({ + success: false + , description: 'Forbidden: privileged operation (admin)' + }) + } req.user = user next() } else { return res.status(500).json({ success: false + , description: 'Internal Server Error' }) } }) @@ -86,6 +101,7 @@ function accessTokenAuth(req, res, next) { else { return res.status(500).json({ success: false + , description: 'Internal Server Error' }) } }) diff --git a/lib/units/api/index.js b/lib/units/api/index.js index f89e4620..26ca9de1 100644 --- a/lib/units/api/index.js +++ b/lib/units/api/index.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var http = require('http') var path = require('path') var events = require('events') @@ -51,16 +55,51 @@ module.exports = function(options) { lifecycle.fatal() }) + var pushdev = zmqutil.socket('push') + Promise.map(options.endpoints.pushdev, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Sending output to "%s"', record.url) + pushdev.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to pushdev endpoint', err) + lifecycle.fatal() + }) + + var subdev = zmqutil.socket('sub') + Promise.map(options.endpoints.subdev, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Receiving input from "%s"', record.url) + subdev.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to subdev endpoint', err) + lifecycle.fatal() + }) + // Establish always-on channels ;[wireutil.global].forEach(function(channel) { log.info('Subscribing to permanent channel "%s"', channel) sub.subscribe(channel) + subdev.subscribe(channel) }) sub.on('message', function(channel, data) { channelRouter.emit(channel.toString(), channel, data) }) + subdev.on('message', function(channel, data) { + channelRouter.emit(channel.toString(), channel, data) + }) + // Swagger Express Config var config = { appRoot: __dirname @@ -81,6 +120,8 @@ module.exports = function(options) { push: push , sub: sub , channelRouter: channelRouter + , pushdev: pushdev + , subdev: subdev }) req.options = reqOptions @@ -94,7 +135,7 @@ module.exports = function(options) { })) lifecycle.observe(function() { - [push, sub].forEach(function(sock) { + [push, sub, pushdev, subdev].forEach(function(sock) { try { sock.close() } diff --git a/lib/units/api/swagger/api_v1.yaml b/lib/units/api/swagger/api_v1.yaml index 99d621d6..05f2a6a6 100644 --- a/lib/units/api/swagger/api_v1.yaml +++ b/lib/units/api/swagger/api_v1.yaml @@ -1,6 +1,10 @@ +## +# Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +## + swagger: "2.0" info: - version: "2.3.0" + version: "2.4.0" title: Smartphone Test Farm description: Control and manages real Smartphone devices from browser and restful apis license: @@ -22,9 +26,1271 @@ produces: tags: - name: user description: User Operations + - name: users + description: Users Operations - name: devices description: Device Operations + - name: groups + description: Groups Operations + - name: admin + description: Privileged Operations paths: + /groups: + x-swagger-router-controller: groups + get: + summary: Gets groups + description: Returns the groups to which you belong + operationId: getGroups + tags: + - groups + parameters: + - name: fields + in: query + description: Comma-seperated list of fields; only listed fields will be returned in response + required: false + type: string + - name: owner + in: query + description: Selects the groups for which you are the owner (true) or a simple member (false); note that by not providing this parameter, it means all groups to which you belong are selected + required: false + type: boolean + responses: + "200": + description: Groups information + schema: + $ref: "#/definitions/GroupListResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes groups + description: Removes the groups owned by you + operationId: deleteGroups + tags: + - groups + parameters: + - name: groups + in: body + description: Groups to remove as a comma-separated list of group identifiers; note that by not providing this parameter it means all groups owned by you are removed + required: false + schema: + $ref: "#/definitions/GroupsPayload" + responses: + "200": + description: Groups removing is OK (or no groups to remove) + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => a device is currently booked or unremovable built-in group + * 404: Not Found => unknown groups + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + post: + summary: Creates a group + description: Creates a group with you as owner + operationId: createGroup + tags: + - groups + parameters: + - name: group + in: body + description: Group properties; at least one property is required + required: true + schema: + $ref: "#/definitions/GroupPayload" + responses: + "201": + description: Group information + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => invalid format or semantic of properties + * 401: Unauthorized => bad credentials + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /groups/{id}: + x-swagger-router-controller: groups + get: + summary: Gets a group + description: Returns a group to which you belong + operationId: getGroup + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: fields + in: query + description: Comma-separated list of group fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Group information + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + put: + summary: Updates a group + description: Updates a group owned by you + operationId: updateGroup + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: group + in: body + description: Group properties; at least one property is required + required: true + schema: + $ref: "#/definitions/GroupPayload" + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + "409": + description: Conflicts information + schema: + $ref: "#/definitions/ConflictsResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => invalid format or semantic of properties + * 401: Unauthorized => bad credentials + * 403: Forbidden => quota is reached or unauthorized property + * 404: Not Found => unknown group + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes a group + description: Removes a group owned by you + operationId: deleteGroup + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + responses: + "200": + description: Group removing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => a device is currently booked or unremovable built-in group + * 404: Not Found => unknown group + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /groups/{id}/devices: + x-swagger-router-controller: groups + get: + summary: Gets the devices of a group + description: Returns the devices of the group to which you belong + operationId: getGroupDevices + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: bookable + in: query + description: Selects devices which could be potentially booked by that transient group (true => irrelevant for an origin group!), or selects all devices of the group (false); note that by not providing this parameter all devices of the group are selected + type: boolean + default: false + - name: fields + in: query + description: Comma-separated list of device fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Group devices information + schema: + $ref: "#/definitions/DeviceListResponse" + default: + description: > + Unexpected Error: + * 400: Bad request => group is not transient + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + put: + summary: Adds devices into a transient group + description: Adds devices into a transient group owned by you + operationId: addGroupDevices + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: devices + in: body + description: Devices to add as a comma-separated list of serials; note that by not providing this parameter it means all devices which could be potentially booked by that transient group are added into the latter + required: false + schema: + $ref: "#/definitions/DevicesPayload" + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + "409": + description: Conflicts information + schema: + $ref: "#/definitions/ConflictsResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not transient + * 401: Unauthorized => bad credentials + * 403: Forbidden => quota is reached + * 404: Not Found => unknown group or devices + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes devices from a transient group + description: Removes devices from a transient group owned by you + operationId: removeGroupDevices + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: devices + in: body + description: Devices to remove as a comma-separated list of serials; note that by not providing this parameter it means all devices of the group are removed + required: false + schema: + $ref: "#/definitions/DevicesPayload" + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not transient + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group or devices + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /groups/{id}/devices/{serial}: + x-swagger-router-controller: groups + get: + summary: Gets a device of a group + description: Returns a device of a group to which you belong + operationId: getGroupDevice + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: fields + in: query + description: Comma-separated list of device fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Group device information + schema: + $ref: "#/definitions/DeviceResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group or device + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + put: + summary: Adds a device into a transient group + description: Adds a device into a transient group owned by you + operationId: addGroupDevice + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + "409": + description: Conflicts information + schema: + $ref: "#/definitions/ConflictsResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not transient + * 401: Unauthorized => bad credentials + * 403: Forbidden => quota is reached + * 404: Not Found => unknown group or device + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes a device from a transient group + description: Removes a device from a transient group owned by you + operationId: removeGroupDevice + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not transient + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group or device + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /groups/{id}/users: + x-swagger-router-controller: groups + get: + summary: Gets the users of a group + description: Gets the users of a group to which you belong; if you are the administrator user then all user fields are returned, otherwise only 'email', 'name' and 'privilege' user fields are returned + operationId: getGroupUsers + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: fields + in: query + description: Comma-separated list of user fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Group users information + schema: + $ref: "#/definitions/UserListResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + put: + summary: Adds users into a group + description: Adds users into a group owned by you + operationId: addGroupUsers + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: users + in: body + description: Users to add as a comma-separated list of emails; note that by not providing this parameter it means all available users are added into the group + required: false + schema: + $ref: "#/definitions/UsersPayload" + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group or device or users + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes users from a group + description: Removes users from a group owned by you + operationId: removeGroupUsers + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: users + in: body + description: Users to remove as a comma-separated list of emails; note that by not providing this parameter it means all users of the group are removed + required: false + schema: + $ref: "#/definitions/UsersPayload" + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => owner or administrator user can't be removed + * 404: Not Found => unknown group or device or users + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /groups/{id}/users/{email}: + x-swagger-router-controller: groups + get: + summary: Gets a user of a group + description: Gets a user of a group to which you belong; if you are the administrator user then all user fields are returned, otherwise only 'email', 'name' and 'privilege' user fields are returned + operationId: getGroupUser + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: fields + in: query + description: Comma-separated list of user fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Group user information + schema: + $ref: "#/definitions/UserResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group or device or user + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + put: + summary: Adds a user into a group + description: Adds a user into a group owned by you + operationId: addGroupUser + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: email + in: path + description: User identifier (email) + required: true + type: string + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown group or device or user + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes a user from a group + description: Removes a user from a group owned by you + operationId: removeGroupUser + tags: + - groups + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: email + in: path + description: User identifier (email) + required: true + type: string + responses: + "200": + description: Group information (an empty group is returned if no change is made) + schema: + $ref: "#/definitions/GroupResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => owner or administrator user can't be removed + * 404: Not Found => unknown group or device or user + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users: + x-swagger-router-controller: users + get: + summary: Gets users + description: gets users; if you are the administrator user then all user fields are returned, otherwise only 'email', 'name' and 'privilege' user fields are returned + operationId: getUsers + tags: + - users + parameters: + - name: fields + in: query + description: Comma-separated list of user fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Users information + schema: + $ref: "#/definitions/UserListResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes users + description: Removes users from the database + operationId: deleteUsers + tags: + - admin + parameters: + - name: groupOwner + in: query + description: Allows or not the removing of each user depending respectively if the user is a group owner (true) or not (false); note that by not providing the groupOwner parameter it means an unconditionally removing + required: false + type: boolean + - name: users + in: body + description: Users to remove as a comma-separated list of emails; note that by not providing this parameter it means all users are selected for removing + required: false + schema: + $ref: "#/definitions/UsersPayload" + responses: + "200": + description: Users removing is OK (or no users to remove) + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => administrator user can't be removed + * 404: Not Found => unknown users + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/groupsQuotas: + x-swagger-router-controller: users + put: + summary: Updates the default groups quotas of users + description: Updates the default groups quotas allocated to each new user + operationId: updateDefaultUserGroupsQuotas + tags: + - admin + parameters: + - name: number + in: query + description: Number of groups + required: false + type: integer + minimum: 0 + - name: duration + in: query + description: Total duration of groups (milliseconds) + required: false + type: integer + minimum: 0 + - name: repetitions + in: query + description: Number of repetitions per Group + required: false + type: integer + minimum: 0 + responses: + "200": + description: Administrator user information (an empty user is returned if no change is made) + schema: + $ref: "#/definitions/UserResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/{email}: + x-swagger-router-controller: users + post: + summary: Creates a user + description: Creates a user in the database + operationId: createUser + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: name + in: query + description: User name + required: true + type: string + responses: + "201": + description: User information + schema: + $ref: "#/definitions/UserResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => user already exists + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + get: + summary: Gets a user + description: Gets a user; if you are the administrator user then all user fields are returned, otherwise only 'email', 'name' and 'privilege' user fields are returned + operationId: getUserByEmail + tags: + - users + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: fields + in: query + description: Comma-separated list of user fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: User information + schema: + $ref: "#/definitions/UserResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes a user + description: Removes a user from the database + operationId: deleteUser + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: groupOwner + in: query + description: Allows or not the removing of the user depending respectively if the user is a group owner (true) or not (false); note that by not providing this parameter it means an unconditionally removing + required: false + type: boolean + responses: + "200": + description: User removing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => administrator user can't be removed + * 404: Not Found => unknown user + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/{email}/groupsQuotas: + x-swagger-router-controller: users + put: + summary: Updates the groups quotas of a user + description: Updates the groups quotas of a user + operationId: updateUserGroupsQuotas + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: number + in: query + description: Number of groups + required: false + type: integer + minimum: 0 + - name: duration + in: query + description: Total duration of groups (milliseconds) + required: false + type: integer + minimum: 0 + - name: repetitions + in: query + description: Number of repetitions per Group + required: false + type: integer + minimum: 0 + responses: + "200": + description: User information (an empty user is returned if no change is made) + schema: + $ref: "#/definitions/UserResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => quotas must be >= actual consumed resources + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/{email}/devices: + x-swagger-router-controller: users + get: + summary: Gets the devices controlled by a user + description: Gets the devices controlled by a user + operationId: getUserDevicesV2 + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: fields + in: query + description: Comma-separated list of device fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Controlled devices information + schema: + $ref: "#/definitions/DeviceListResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/{email}/devices/{serial}: + x-swagger-router-controller: users + post: + summary: Places a device under user control + description: Places a device under user control; note this is not completely analogous to press the 'Use' button in the UI because that does not authorize remote connection through ADB + operationId: addUserDeviceV3 + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: timeout + in: query + description: Means the device will be automatically removed from the user control if it is kept idle for this period (in milliseconds); default value is provided by the provider 'group timeout' + required: false + type: integer + minimum: 0 + responses: + "200": + description: Device controlling is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => Device is already controlled or is not available + * 404: Not Found => unknown user or device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + get: + summary: Gets a device controlled by a user + description: Gets a device controlled by a user + operationId: getUserDevice + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: serial + in: path + description: Device identifier (Serial) + required: true + type: string + - name: fields + in: query + description: Comma-separated list of device fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Controlled device information + schema: + $ref: "#/definitions/DeviceResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown user or device + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Remove a device from the user control + description: Remove a device from the user control; note this is analogous to press the 'Stop Using' button in the UI because that forbids also remote connection through ADB + operationId: deleteUserDevice + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + responses: + "200": + description: Device releasing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown user or device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/{email}/devices/{serial}/remoteConnect: + x-swagger-router-controller: users + post: + summary: Allows to remotely connect to a device controlled by a user + description: Allows to remotely connect to a device controlled by a user; returns the remote debug URL in response for use with ADB + operationId: remoteConnectUserDevice + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + responses: + "200": + description: Remote debug URL + schema: + $ref: "#/definitions/RemoteConnectUserDeviceResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown user or device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Forbids to remotely connect to a device controlled by a user + description: Forbids using ADB to remotely connect to a device controlled by a user + operationId: remoteDisconnectUserDevice + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + responses: + "200": + description: Remote debug URL disabling is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown user or device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/{email}/accessTokens: + x-swagger-router-controller: users + post: + summary: Create an access token for a user + description: Creates an access token for a user. + operationId: createUserAccessToken + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: title + in: query + description: Access token title + required: true + type: string + responses: + "200": + description: Access token information + schema: + $ref: "#/definitions/UserAccessTokenResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + get: + summary: Gets the access tokens of a user + description: Gets the access tokens of a user + operationId: getUserAccessTokensV2 + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + responses: + "200": + description: Access tokens information + schema: + $ref: "#/definitions/UserAccessTokensResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Remove the access tokens of a user + description: Remove the access tokens of a user + operationId: deleteUserAccessTokens + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + responses: + "200": + description: Access tokens removing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /users/{email}/accessTokens/{id}: + x-swagger-router-controller: users + get: + summary: Gets an access token of a user + description: Gets an access token of a user + operationId: getUserAccessToken + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: id + in: path + description: Access token identifier + required: true + type: string + responses: + "200": + description: Access token information + schema: + $ref: "#/definitions/UserAccessTokenResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user or token + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes an access token of a user + description: Removes an access token of a user + operationId: deleteUserAccessToken + tags: + - admin + parameters: + - name: email + in: path + description: User identifier (email) + required: true + type: string + - name: id + in: path + description: Access token identifier + required: true + type: string + responses: + "200": + description: Access token removing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown user or token + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] /user: x-swagger-router-controller: user get: @@ -64,9 +1330,12 @@ paths: schema: $ref: "#/definitions/DeviceListResponse" default: - description: Unexpected Error + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error schema: - $ref: "#/definitions/ErrorResponse" + $ref: "#/definitions/UnexpectedErrorResponse" security: - accessTokenAuth: [] post: @@ -86,13 +1355,54 @@ paths: "200": description: Add User Device Status default: - description: Unexpected Error + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is already controlled or is not available + * 404: Not Found => unknown device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding schema: - $ref: "#/definitions/ErrorResponse" + $ref: "#/definitions/UnexpectedErrorResponse" security: - accessTokenAuth: [] /user/devices/{serial}: x-swagger-router-controller: user + post: + summary: Places a device under user control + description: Places a device under user control; note this is not completely analogous to press the 'Use' button in the UI because that does not authorize remote connection through ADB + operationId: addUserDeviceV2 + tags: + - user + parameters: + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: timeout + in: query + description: Means the device will be automatically removed from the user control if it is kept idle for this period (in milliseconds); default value is provided by the provider 'group timeout' + required: false + type: integer + minimum: 0 + responses: + "200": + description: Device controlling is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is already controlled or is not available + * 404: Not Found => unknown device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] get: summary: User Device description: The devices enpoint return information about device owned by user @@ -116,9 +1426,14 @@ paths: schema: $ref: "#/definitions/DeviceResponse" default: - description: Unexpected Error + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown device + * 500: Internal Server Error schema: - $ref: "#/definitions/ErrorResponse" + $ref: "#/definitions/UnexpectedErrorResponse" security: - accessTokenAuth: [] delete: @@ -136,10 +1451,18 @@ paths: responses: "200": description: Delete User Device Status - default: - description: Unexpected Error schema: - $ref: "#/definitions/ErrorResponse" + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" security: - accessTokenAuth: [] # I do know this is against REST principal to use verb as endpoint. But I feel it is more easy to @@ -164,9 +1487,15 @@ paths: schema: $ref: "#/definitions/RemoteConnectUserDeviceResponse" default: - description: Unexpected Error + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding schema: - $ref: "#/definitions/ErrorResponse" + $ref: "#/definitions/UnexpectedErrorResponse" security: - accessTokenAuth: [] delete: @@ -184,14 +1513,70 @@ paths: responses: "200": description: Remote Disonnect User Device Request Status - default: - description: Unexpected Error schema: - $ref: "#/definitions/ErrorResponse" + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 403: Forbidden => device is not controlled by the user + * 404: Not Found => unknown device + * 500: Internal Server Error + * 504: Gateway Time-out => device is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /user/fullAccessTokens: + x-swagger-router-controller: user + get: + summary: Gets your access tokens + description: Gets your access tokens; note that all fields are returned in reponse including the 'id' one + operationId: getAccessTokens + tags: + - user + responses: + "200": + description: Access tokens information + schema: + $ref: "#/definitions/UserAccessTokensResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" security: - accessTokenAuth: [] /user/accessTokens: x-swagger-router-controller: user + post: + summary: Create an access token + description: Create an access token for you + operationId: createAccessToken + tags: + - user + parameters: + - name: title + in: query + description: Access token title + required: true + type: string + responses: + "201": + description: Access token information + schema: + $ref: "#/definitions/UserAccessTokenResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] get: summary: Access Tokens description: The Access Tokens endpoints returns titles of all the valid access tokens @@ -209,8 +1594,84 @@ paths: $ref: "#/definitions/ErrorResponse" security: - accessTokenAuth: [] + delete: + summary: Removes your access tokens + description: Removes your access tokens + operationId: deleteAccessTokens + tags: + - user + responses: + "200": + description: Access tokens removing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /user/accessTokens/{id}: + x-swagger-router-controller: user + get: + summary: Gets an access token + description: Gets one of your access tokens + operationId: getAccessToken + tags: + - user + parameters: + - name: id + in: path + description: Access token identifier + required: true + type: string + responses: + "200": + description: Access token information + schema: + $ref: "#/definitions/UserAccessTokenResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown token + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes an access token + description: Removes one of your access tokens + operationId: deleteAccessToken + tags: + - user + parameters: + - name: id + in: path + description: Access token identifier + required: true + type: string + responses: + "200": + description: Access token removing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown token + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] /devices: - x-swagger-router-controller: devices + x-swagger-router-controller: devices get: summary: Device List description: The devices endpoint return list of all the STF devices including Disconnected and Offline @@ -218,6 +1679,23 @@ paths: tags: - devices parameters: + - name: target + in: query + description: > + Targets devices of your universe: + * bookable - devices belonging to a bookable group + * standard - devices belonging to a standard group + * origin - all devices + * standardizable - devices which are not yet booked including those belonging to a standard group + * user (default value) - devices which are accessible by you at a given time + type: string + enum: + - bookable + - standard + - origin + - standardizable + - user + default: user - name: fields in: query description: Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response @@ -225,20 +1703,72 @@ paths: type: string responses: "200": - description: List of Devices + description: Devices information schema: $ref: "#/definitions/DeviceListResponse" default: - description: Unexpected Error + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 500: Internal Server Error schema: - $ref: "#/definitions/ErrorResponse" + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes devices + description: Removes devices from the database + operationId: deleteDevices + tags: + - admin + parameters: + - name: present + in: query + description: Allows or not the removing of each device depending respectively if the device is present (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + - name: booked + in: query + description: Allows or not the removing of each device depending respectively if the device is booked (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + - name: annotated + in: query + description: Allows or not the removing of each device depending respectively if the device is annotated (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + - name: controlled + in: query + description: Allows or not the removing of each device depending respectively if the device is controlled (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + - name: devices + in: body + description: Devices to remove as a comma-separated list of serials; note that by not providing this parameter it means all devices are selected for removing + required: false + schema: + $ref: "#/definitions/DevicesPayload" + responses: + "200": + description: Devices removing is OK (or no devices to remove) + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown devices + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" security: - accessTokenAuth: [] /devices/{serial}: x-swagger-router-controller: devices get: summary: Device Information - description: The device enpoint return information about a single device + description: The devices serial enpoint return information about a single device operationId: getDeviceBySerial tags: - devices @@ -264,45 +1794,492 @@ paths: $ref: "#/definitions/ErrorResponse" security: - accessTokenAuth: [] + delete: + summary: Removes a device + description: Removes a device from the database + operationId: deleteDevice + tags: + - admin + parameters: + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: present + in: query + description: Allows or not the removing of the device depending respectively if the device is present (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + - name: booked + in: query + description: Allows or not the removing of the device depending respectively if the device is booked (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + - name: annotated + in: query + description: Allows or not the removing of the device depending respectively if the device is annotated (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + - name: controlled + in: query + description: Allows or not the removing of the device depending respectively if the device is controlled (true) or not (false); note that by not providing this parameter it means an unconditional removing + required: false + type: boolean + responses: + "200": + description: Device removing is OK + schema: + $ref: "#/definitions/Response" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown device + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /devices/groups/{id}: + x-swagger-router-controller: devices + put: + summary: Adds devices into an origin group + description: Adds devices into an origin group along with updating each added device; returns the updated devices + operationId: addOriginGroupDevices + tags: + - admin + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: devices + in: body + description: > + Devices to add as a comma-separated list of serials; note that by not providing this parameter it means all 'available devices' are selected for adding: + * 'availables devices' means all devices in case of a bookable group + * 'availables devices' means all not yet booked devices in case of a standard group + required: false + schema: + $ref: "#/definitions/DevicesPayload" + - name: fields + in: query + description: Comma-seperated list of fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Devices information (an empty device list is returned if no change is made) + schema: + $ref: "#/definitions/DeviceListResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not an origin one + * 401: Unauthorized => bad credentials + * 403: Fobidden => a device is currently booked + * 404: Not Found => unknown group or devices + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + * 504: Gateway Time-out => server is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes devices from an origin group + description: Removes devices from an origin group along with updating each removed device; returns the updated devices + operationId: removeOriginGroupDevices + tags: + - admin + parameters: + - name: id + in: path + description: Group identifier + required: true + type: string + - name: devices + in: body + description: Devices to remove as a comma-separated list of serials; note that by not providing this parameter it means all devices of the group are selected for removing + required: false + schema: + $ref: "#/definitions/DevicesPayload" + - name: fields + in: query + description: Comma-seperated list of fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Devices information (an empty device list is returned if no change is made) + schema: + $ref: "#/definitions/DeviceListResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not an origin one + * 401: Unauthorized => bad credentials + * 403: Fobidden => a device is currently booked + * 404: Not Found => unknown group or devices + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + * 504: Gateway Time-out => server is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /devices/{serial}/groups: + x-swagger-router-controller: devices + get: + summary: Gets the groups to which the device belongs + description: Gets the groups to which the device belongs + operationId: getDeviceGroups + tags: + - admin + parameters: + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: fields + in: query + description: Comma-seperated list of fields; only listed fields will be returned in response + required: false + type: string + responses: + "200": + description: Groups information + schema: + $ref: "#/definitions/GroupListResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown device + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /devices/{serial}/bookings: + x-swagger-router-controller: devices + get: + summary: Gets the bookings to which the device belongs + description: Gets the bookings (i.e. transient groups) to which the device belongs + operationId: getDeviceBookings + tags: + - devices + parameters: + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: fields + in: query + description: Fields query parameter takes a comma seperated list of fields. Only listed field will be return in response + required: false + type: string + responses: + "200": + description: Bookings information + schema: + $ref: "#/definitions/GroupListResponse" + default: + description: > + Unexpected Error: + * 401: Unauthorized => bad credentials + * 404: Not Found => unknown device + * 500: Internal Server Error + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + /devices/{serial}/groups/{id}: + x-swagger-router-controller: devices + put: + summary: Adds a device into an origin group + description: Adds a device into an origin group along with updating the added device; returns the updated device + operationId: addOriginGroupDevice + tags: + - admin + parameters: + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: id + in: path + description: Group identifier + required: true + type: string + responses: + "200": + description: Device information + schema: + $ref: "#/definitions/DeviceResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not an origin one + * 401: Unauthorized => bad credentials + * 403: Fobidden => the device is currently booked + * 404: Not Found => unknown group or device + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + * 504: Gateway Time-out => server is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] + delete: + summary: Removes a device from an origin group + description: Removes a device from an origin group along with updating the removed device; returns the updated device + operationId: removeOriginGroupDevice + tags: + - admin + parameters: + - name: serial + in: path + description: Device identifier (serial) + required: true + type: string + - name: id + in: path + description: Group identifier + required: true + type: string + responses: + "200": + description: Device information + schema: + $ref: "#/definitions/DeviceResponse" + default: + description: > + Unexpected Error: + * 400: Bad Request => group is not an origin one + * 401: Unauthorized => bad credentials + * 403: Fobidden => the device is currently booked + * 404: Not Found => unknown group or device + * 500: Internal Server Error + * 503: Service Unavailable => server too busy or a lock on a resource is pending + * 504: Gateway Time-out => server is not responding + schema: + $ref: "#/definitions/UnexpectedErrorResponse" + security: + - accessTokenAuth: [] /swagger.json: x-swagger-pipe: swagger_raw definitions: + UnexpectedErrorResponse: + required: + - success + - description + properties: + success: + type: boolean + default: false + description: + type: string + Response: + required: + - success + - description + properties: + success: + type: boolean + default: true + description: + type: string + GroupResponse: + required: + - success + - description + - group + properties: + success: + type: boolean + description: + type: string + group: + description: A null value means the group is unchanged + type: object + Conflict: + type: object + properties: + devices: + description: Devices in conflict + type: array + items: + type: string + date: + description: Timeslot in conflict + type: object + properties: + start: + type: string + format: date-time + stop: + type: string + format: date-time + group: + description: Name of the group in conflict + type: string + owner: + description: Owner of the group in conflict + type: object + properties: + email: + type: string + name: + type: string + ConflictsResponse: + required: + - success + - description + - conflicts + properties: + success: + type: boolean + description: + type: string + conflicts: + description: > + List of conflicts with the current group operation: + * adding a device into the group + * updating the schedule of the group + type: array + items: + $ref: '#/definitions/Conflict' + GroupListResponse: + required: + - success + - description + - groups + properties: + success: + type: boolean + description: + type: string + groups: + type: array + items: + type: object + UserListResponse: + required: + - success + - description + - users + properties: + success: + type: boolean + description: + type: string + users: + type: array + items: + type: object UserResponse: required: + - success + - description - user properties: + success: + type: boolean + description: + type: string user: type: object - AccessTokensResponse: + Token: + type: object + properties: + id: + type: string + title: + type: string + UserAccessTokenResponse: required: + - success + - description + - token + properties: + success: + type: boolean + description: + type: string + token: + $ref: '#/definitions/Token' + UserAccessTokensResponse: + required: + - success + - description - tokens properties: + success: + type: boolean + description: + type: string + tokens: + type: array + items: + $ref: '#/definitions/Token' + AccessTokensResponse: + required: + - success + - description + - tokens + properties: + success: + type: boolean + description: + type: string tokens: type: array items: type: string DeviceListResponse: required: + - success + - description - devices properties: + success: + type: boolean + description: + type: string devices: type: array items: type: object DeviceResponse: required: + - success + - description - device properties: + success: + type: boolean + description: + type: string device: type: object RemoteConnectUserDeviceResponse: required: + - success + - description - remoteConnectUrl - - serial properties: - remoteConnectUrl: + success: + type: boolean + description: type: string - serial: + remoteConnectUrl: type: string AddUserDevicePayload: description: payload object for adding device to user @@ -315,6 +2292,65 @@ definitions: timeout: description: Device timeout in ms. If device is kept idle for this period, it will be automatically disconnected. Default is provider group timeout type: integer + GroupPayload: + description: Payload object for creating/updating a group + properties: + name: + description: Group Name; default value => generated at runtime + type: string + pattern: '^[0-9a-zA-Z-_./: ]{1,50}$' + startTime: + description: Group starting time; default value => group creation time + type: string + format: date-time + stopTime: + description: Group expiration time; default value => startTime + 1 hour + type: string + format: date-time + class: + description: Group class; privileged value => debug, bookable, standard + type: string + enum: + - once + - bookable + - hourly + - daily + - weekly + - monthly + - quaterly + - halfyearly + - yearly + - debug + - standard + default: once + repetitions: + description: Group repetitions; default value => 0 + type: integer + minimum: 0 + state: + description: Group state; default value => pending or ready for bookable/standard classes + type: string + enum: + - pending + - ready + GroupsPayload: + description: Payload object for adding/removing groups + properties: + ids: + description: Comma-separated list of identifiers + type: string + UsersPayload: + description: Payload object for adding/removing users + properties: + emails: + description: Comma-separated list of emails + type: string + DevicesPayload: + description: Payload object for adding/removing devices + properties: + serials: + description: Comma-separated list of serials + type: string ErrorResponse: required: - message diff --git a/lib/units/app/middleware/auth.js b/lib/units/app/middleware/auth.js index e960f4f3..bf3a5595 100644 --- a/lib/units/app/middleware/auth.js +++ b/lib/units/app/middleware/auth.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var jwtutil = require('../../../util/jwtutil') var urlutil = require('../../../util/urlutil') @@ -18,6 +22,7 @@ module.exports = function(options) { }) .then(function() { req.session.jwt = data + req.sessionOptions.httpOnly = false res.redirect(redir) }) .catch(next) diff --git a/lib/units/auth/ldap.js b/lib/units/auth/ldap.js index 5388407e..c0f6b61c 100644 --- a/lib/units/auth/ldap.js +++ b/lib/units/auth/ldap.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var http = require('http') var express = require('express') @@ -16,6 +20,8 @@ var pathutil = require('../../util/pathutil') var urlutil = require('../../util/urlutil') var lifecycle = require('../../util/lifecycle') +const dbapi = require('../../db/api') + module.exports = function(options) { var log = logger.createLogger('auth-ldap') var app = express() @@ -54,6 +60,24 @@ module.exports = function(options) { res.redirect('/auth/ldap/') }) + app.get('/auth/contact', function(req, res) { + dbapi.getRootGroup().then(function(group) { + res.status(200) + .json({ + success: true + , contact: group.owner + }) + }) + .catch(function(err) { + log.error('Unexpected error', err.stack) + res.status(500) + .json({ + success: false + , error: 'ServerError' + }) + }) + }) + app.get('/auth/ldap/', function(req, res) { res.render('index') }) diff --git a/lib/units/auth/mock.js b/lib/units/auth/mock.js index dc65b770..f3ed4932 100644 --- a/lib/units/auth/mock.js +++ b/lib/units/auth/mock.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var http = require('http') var express = require('express') @@ -16,6 +20,8 @@ var pathutil = require('../../util/pathutil') var urlutil = require('../../util/urlutil') var lifecycle = require('../../util/lifecycle') +const dbapi = require('../../db/api') + module.exports = function(options) { var log = logger.createLogger('auth-mock') var app = express() @@ -80,6 +86,24 @@ module.exports = function(options) { res.redirect('/auth/mock/') }) + app.get('/auth/contact', function(req, res) { + dbapi.getRootGroup().then(function(group) { + res.status(200) + .json({ + success: true + , contact: group.owner + }) + }) + .catch(function(err) { + log.error('Unexpected error', err.stack) + res.status(500) + .json({ + success: false + , error: 'ServerError' + }) + }) + }) + app.get('/auth/mock/', function(req, res) { res.render('index') }) diff --git a/lib/units/device/plugins/connect.js b/lib/units/device/plugins/connect.js index 90bf3f79..b70efc34 100644 --- a/lib/units/device/plugins/connect.js +++ b/lib/units/device/plugins/connect.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var util = require('util') var syrup = require('stf-syrup') @@ -117,6 +121,7 @@ module.exports = syrup.serial() if (plugin.isRunning()) { activeServer.close() activeServer.end() + activeServer = null } }) @@ -131,7 +136,7 @@ module.exports = syrup.serial() } lifecycle.observe(plugin.stop) - group.on('leave', plugin.end) + group.on('leave', plugin.stop) router .on(wire.ConnectStartMessage, function(channel) { @@ -163,7 +168,7 @@ module.exports = syrup.serial() }) .on(wire.ConnectStopMessage, function(channel) { var reply = wireutil.reply(options.serial) - plugin.end() + plugin.stop() .then(function() { push.send([ channel @@ -187,6 +192,5 @@ module.exports = syrup.serial() }) }) - return plugin.start() - .return(plugin) + return(plugin) }) diff --git a/lib/units/groups-engine/index.js b/lib/units/groups-engine/index.js new file mode 100644 index 00000000..2a5ad76b --- /dev/null +++ b/lib/units/groups-engine/index.js @@ -0,0 +1,115 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const events = require('events') +const Promise = require('bluebird') +const logger = require('../../util/logger') +const zmqutil = require('../../util/zmqutil') +const srv = require('../../util/srv') +const lifecycle = require('../../util/lifecycle') +const wireutil = require('../../wire/util') + +const groupsScheduler = require('./scheduler') +const groupsWatcher = require('./watchers/groups') +const devicesWatcher = require('./watchers/devices') +const usersWatcher = require('./watchers/users') + +module.exports = function(options) { + const log = logger.createLogger('groups-engine') + const channelRouter = new events.EventEmitter() + + const push = zmqutil.socket('push') + Promise.map(options.endpoints.push, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Sending output to "%s"', record.url) + push.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to push endpoint', err) + lifecycle.fatal() + }) + + // Input + const sub = zmqutil.socket('sub') + Promise.map(options.endpoints.sub, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Receiving input from "%s"', record.url) + sub.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to sub endpoint', err) + lifecycle.fatal() + }) + + const pushdev = zmqutil.socket('push') + Promise.map(options.endpoints.pushdev, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Sending output to "%s"', record.url) + pushdev.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to pushdev endpoint', err) + lifecycle.fatal() + }) + + const subdev = zmqutil.socket('sub') + Promise.map(options.endpoints.subdev, function(endpoint) { + return srv.resolve(endpoint).then(function(records) { + return srv.attempt(records, function(record) { + log.info('Receiving input from "%s"', record.url) + subdev.connect(record.url) + return Promise.resolve(true) + }) + }) + }) + .catch(function(err) { + log.fatal('Unable to connect to subdev endpoint', err) + lifecycle.fatal() + }) + + // Establish always-on channels + ;[wireutil.global].forEach(function(channel) { + log.info('Subscribing to permanent channel "%s"', channel) + sub.subscribe(channel) + subdev.subscribe(channel) + }) + + sub.on('message', function(channel, data) { + channelRouter.emit(channel.toString(), channel, data) + }) + + subdev.on('message', function(channel, data) { + channelRouter.emit(channel.toString(), channel, data) + }) + + groupsScheduler() + groupsWatcher(push, pushdev, channelRouter) + devicesWatcher(push, pushdev, channelRouter) + usersWatcher(pushdev) + + lifecycle.observe(function() { + [push, sub, pushdev, subdev].forEach(function(sock) { + try { + sock.close() + } + catch (err) { + // No-op + } + }) + }) + + log.info('Groups engine started') +} diff --git a/lib/units/groups-engine/scheduler/index.js b/lib/units/groups-engine/scheduler/index.js new file mode 100644 index 00000000..67e70738 --- /dev/null +++ b/lib/units/groups-engine/scheduler/index.js @@ -0,0 +1,156 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const Promise = require('bluebird') +const logger = require('../../../util/logger') +const apiutil = require('../../../util/apiutil') +const db = require('../../../db') +const dbapi = require('../../../db/api') +const r = require('rethinkdb') + +module.exports = function() { + const log = logger.createLogger('groups-scheduler') + + function updateOriginGroupLifetime(group) { + const lock = {} + + return dbapi.adminLockGroup(group.id, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + const now = Date.now() + + return db.run(r.table('groups').get(group.id).update({ + dates: [{ + start: new Date(now) + , stop: new Date(now + (group.dates[0].stop - group.dates[0].start)) + }] + })) + } + return false + }) + .finally(function() { + return dbapi.adminUnlockGroup(lock) + }) + } + + function deleteUserGroup(group) { + const lock = {} + + return dbapi.adminLockGroup(group.id, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + return dbapi.deleteUserGroup(group.id) + } + else { + return db.run(r.table('groups').get(group.id).update({ + isActive: false + , state: apiutil.WAITING + })) + } + }) + .finally(function() { + return dbapi.adminUnlockGroup(lock) + }) + } + + function updateGroupDates(group, incr, isActive) { + const repetitions = group.repetitions - incr + const dates = group.dates.slice(incr) + const duration = group.devices.length * (dates[0].stop - dates[0].start) * (repetitions + 1) + + return db.run(r.table('groups').get(group.id).update({ + dates: dates + , repetitions: repetitions + , duration: duration + , isActive: isActive + , state: apiutil.READY + })) + .then(function() { + return dbapi.updateUserGroupDuration(group.owner.email, group.duration, duration) + }) + } + + function doBecomeUnactiveGroup(group) { + const lock = {} + + return dbapi.adminLockGroup(group.id, lock).then(function(lockingSuccessed) { + if (lockingSuccessed) { + return updateGroupDates(group, 1, false) + } + else { + return db.run(r.table('groups').get(group.id).update({ + isActive: false + , state: apiutil.WAITING + })) + } + }) + .finally(function() { + return dbapi.adminUnlockGroup(lock) + }) + } + + function doCleanElapsedGroupDates(group, incr) { + const lock = {} + + return dbapi.adminLockGroup(group.id, lock).then(function(lockingSuccessed) { + return lockingSuccessed ? updateGroupDates(group, incr, false) : false + }) + .finally(function() { + return dbapi.adminUnlockGroup(lock) + }) + } + + function doBecomeActiveGroup(group, incr) { + const lock = {} + + return dbapi.adminLockGroup(group.id, lock).then(function(lockingSuccessed) { + return lockingSuccessed ? updateGroupDates(group, incr, true) : false + }) + .finally(function() { + return dbapi.adminUnlockGroup(lock) + }) + } + + dbapi.unlockBookingObjects().then(function() { + setInterval(function() { + const now = Date.now() + + dbapi.getReadyGroupsOrderByIndex('startTime').then(function(groups) { + Promise.each(groups, (function(group) { + if (apiutil.isOriginGroup(group.class)) { + if (now >= group.dates[0].stop.getTime()) { + return updateOriginGroupLifetime(group) + } + } + else if ((group.isActive || group.state === apiutil.WAITING) && + now >= group.dates[0].stop.getTime()) { + if (group.dates.length === 1) { + return deleteUserGroup(group) + } + else { + return doBecomeUnactiveGroup(group) + } + } + else if (!group.isActive) { + for(const i in group.dates) { + if (now >= group.dates[i].stop.getTime()) { + if (group.dates[i].stop === group.dates[group.dates.length - 1].stop) { + return deleteUserGroup(group) + } + } + else if (now < group.dates[i].start.getTime()) { + return i > 0 ? doCleanElapsedGroupDates(group, i) : false + } + else { + return doBecomeActiveGroup(group, i) + } + } + } + return false + })) + }) + .catch(function(err) { + log.error('An error occured during groups scheduling', err.stack) + }) + }, 1000) + }) +} diff --git a/lib/units/groups-engine/watchers/devices.js b/lib/units/groups-engine/watchers/devices.js new file mode 100644 index 00000000..608b4434 --- /dev/null +++ b/lib/units/groups-engine/watchers/devices.js @@ -0,0 +1,254 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const wirerouter = require('../../../wire/router') +const _ = require('lodash') +const r = require('rethinkdb') +const util = require('util') +const uuid = require('uuid') +const logger = require('../../../util/logger') +const timeutil = require('../../../util/timeutil') +const wireutil = require('../../../wire/util') +const wire = require('../../../wire') +const dbapi = require('../../../db/api') +const db = require('../../../db') + +module.exports = function(push, pushdev, channelRouter) { + const log = logger.createLogger('watcher-devices') + + function sendReleaseDeviceControl(serial, channel) { + push.send([ + channel + , wireutil.envelope( + new wire.UngroupMessage( + wireutil.toDeviceRequirements({ + serial: { + value: serial + , match: 'exact' + } + }) + ) + ) + ]) + } + + function sendDeviceGroupChange(id, group, serial, originName) { + pushdev.send([ + wireutil.global + , wireutil.envelope( + new wire.DeviceGroupChangeMessage( + id + , new wire.DeviceGroupMessage( + group.id + , group.name + , new wire.DeviceGroupOwnerMessage( + group.owner.email + , group.owner.name + ) + , new wire.DeviceGroupLifetimeMessage( + group.dates[0].start.getTime() + , group.dates[0].stop.getTime() + ) + , group.class + , group.repetitions + , originName + ) + , serial + ) + ) + ]) + } + + function sendDeviceChange(device1, device2, action) { + function publishDevice() { + const device = _.cloneDeep(device1) + + delete device.channel + delete device.owner + delete device.group.id + delete device.group.lifeTime + return device + } + + pushdev.send([ + wireutil.global + , wireutil.envelope( + new wire.DeviceChangeMessage( + publishDevice() + , action + , device2.group.origin + , timeutil.now('nano') + ) + ) + ]) + } + + function sendReleaseDeviceControlAndDeviceGroupChange( + device + , sendDeviceGroupChangeWrapper) { + let messageListener + const responseTimer = setTimeout(function() { + channelRouter.removeListener(wireutil.global, messageListener) + sendDeviceGroupChangeWrapper() + }, 5000) + + messageListener = wirerouter() + .on(wire.LeaveGroupMessage, function(channel, message) { + if (message.serial === device.serial && + message.owner.email === device.owner.email) { + clearTimeout(responseTimer) + channelRouter.removeListener(wireutil.global, messageListener) + sendDeviceGroupChangeWrapper() + } + }) + .handler() + + channelRouter.on(wireutil.global, messageListener) + sendReleaseDeviceControl(device.serial, device.channel) + } + + db.run(r + .table('devices') + .pluck( + 'serial' + , 'channel' + , 'owner' + , 'model' + , 'operator' + , 'manufacturer' + , {group: ['id', 'origin', 'originName', 'lifeTime']} + , {provider: ['name']} + , {network: ['type', 'subtype']} + , {display: ['height', 'width']} + , 'version' + , 'sdk' + , 'abi' + , 'cpuPlatform' + , 'openGLESVersion' + , {phone: ['imei']} + ) + .changes(), function(err, cursor) { + if (err) { + throw err + } + return cursor + }) + .then(function(cursor) { + cursor.each(function(err, data) { + if (err) { + throw err + } + if (data.old_val === null) { + return sendDeviceChange(data.new_val, data.new_val, 'created') + } + else if (data.new_val === null) { + sendDeviceChange(data.old_val, data.old_val, 'deleted') + } + else if (data.new_val.model !== data.old_val.model || + data.new_val.group.origin !== data.old_val.group.origin || + data.new_val.operator !== data.old_val.operator || + data.new_val.hasOwnProperty('network') && + (!data.old_val.hasOwnProperty('network') || + data.new_val.network.type !== data.old_val.network.type || + data.new_val.network.subtype !== data.old_val.network.subtype + ) || + data.new_val.provider.name !== data.old_val.provider.name) { + sendDeviceChange(data.new_val, data.old_val, 'updated') + } + + const isDeleted = data.new_val === null + const id = isDeleted ? data.old_val.group.id : data.new_val.group.id + + return dbapi.getGroup(id).then(function(group) { + function sendDeviceGroupChangeOnDeviceDeletion() { + const fakeGroup = Object.assign({}, group) + + fakeGroup.id = util.format('%s', uuid.v4()).replace(/-/g, '') + fakeGroup.name = 'none' + sendDeviceGroupChange( + group.id + , fakeGroup + , data.old_val.serial + , data.old_val.group.originName + ) + } + + function sendDeviceGroupChangeOnDeviceCurrentGroupUpdating() { + sendDeviceGroupChange( + data.old_val.group.id + , group + , data.new_val.serial + , data.new_val.group.originName + ) + } + + if (group) { + if (isDeleted) { + if (data.old_val.owner) { + sendReleaseDeviceControlAndDeviceGroupChange( + data.old_val + , sendDeviceGroupChangeOnDeviceDeletion + ) + return + } + sendDeviceGroupChangeOnDeviceDeletion() + return + } + + const isChangeCurrentGroup = data.new_val.group.id !== data.old_val.group.id + const isChangeOriginGroup = data.new_val.group.origin !== data.old_val.group.origin + const isChangeLifeTime = + data.new_val.group.lifeTime.start.getTime() !== + data.old_val.group.lifeTime.start.getTime() + + if (isChangeLifeTime && !isChangeCurrentGroup && !isChangeOriginGroup) { + sendDeviceGroupChange( + data.old_val.group.id + , group + , data.new_val.serial + , data.new_val.group.originName + ) + return + } + + if (isChangeCurrentGroup) { + if (data.new_val.owner && group.users.indexOf(data.new_val.owner.email) < 0) { + sendReleaseDeviceControlAndDeviceGroupChange( + data.new_val + , sendDeviceGroupChangeOnDeviceCurrentGroupUpdating + ) + } + else { + sendDeviceGroupChangeOnDeviceCurrentGroupUpdating() + } + } + + if (isChangeOriginGroup) { + dbapi.getGroup(data.old_val.group.origin).then(function(originGroup) { + if (originGroup) { + dbapi.removeOriginGroupDevice(originGroup, data.new_val.serial) + } + }) + dbapi.getGroup(data.new_val.group.origin).then(function(originGroup) { + if (originGroup) { + dbapi.addOriginGroupDevice(originGroup, data.new_val.serial) + } + }) + if (!isChangeCurrentGroup) { + sendDeviceGroupChange( + data.new_val.group.id + , group + , data.new_val.serial + , data.new_val.group.originName + ) + } + } + } + }) + }) + }) + .catch(function(err) { + log.error('An error occured during DEVICES table watching', err.stack) + }) +} diff --git a/lib/units/groups-engine/watchers/groups.js b/lib/units/groups-engine/watchers/groups.js new file mode 100644 index 00000000..7bb7e9af --- /dev/null +++ b/lib/units/groups-engine/watchers/groups.js @@ -0,0 +1,346 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const wirerouter = require('../../../wire/router') +const Promise = require('bluebird') +const _ = require('lodash') +const r = require('rethinkdb') +const logger = require('../../../util/logger') +const timeutil = require('../../../util/timeutil') +const apiutil = require('../../../util/apiutil') +const wireutil = require('../../../wire/util') +const wire = require('../../../wire') +const dbapi = require('../../../db/api') +const db = require('../../../db') + +module.exports = function(push, pushdev, channelRouter) { + const log = logger.createLogger('watcher-groups') + + function sendReleaseDeviceControl(serial, channel) { + push.send([ + channel + , wireutil.envelope( + new wire.UngroupMessage( + wireutil.toDeviceRequirements({ + serial: { + value: serial + , match: 'exact' + } + }) + ) + ) + ]) + } + + function sendGroupChange( + group + , subscribers + , isChangedDates + , isChangedClass + , isAddedUser + , users + , isAddedDevice + , devices + , action) { + function dates2String(dates) { + return dates.map(function(date) { + return { + start: date.start.toJSON() + , stop: date.stop.toJSON() + } + }) + } + pushdev.send([ + wireutil.global + , wireutil.envelope( + new wire.GroupChangeMessage( + new wire.GroupField( + group.id + , group.name + , group.class + , group.privilege + , group.owner + , dates2String(group.dates) + , group.duration + , group.repetitions + , group.devices + , group.users + , group.state + , group.isActive + ) + , action + , subscribers + , isChangedDates + , isChangedClass + , isAddedUser + , users + , isAddedDevice + , devices + , timeutil.now('nano') + ) + ) + ]) + } + + function sendGroupUsersChange(group, users, devices, isAdded, action) { + const isDeletedLater = action === 'GroupDeletedLater' + + pushdev.send([ + wireutil.global + , wireutil.envelope( + new wire.GroupUserChangeMessage(users, isAdded, group.id, isDeletedLater, devices)) + ]) + } + + function doUpdateDeviceOriginGroup(group) { + return dbapi.updateDeviceOriginGroup(group.ticket.serial, group).then(function() { + push.send([ + wireutil.global + , wireutil.envelope( + new wire.DeviceOriginGroupMessage(group.ticket.signature) + ) + ]) + }) + } + + function doUpdateDevicesCurrentGroup(group, devices) { + return Promise.map(devices, function(serial) { + return dbapi.updateDeviceCurrentGroup(serial, group) + }) + } + + function doUpdateDevicesCurrentGroupFromOrigin(devices) { + return Promise.map(devices, function(serial) { + return dbapi.updateDeviceCurrentGroupFromOrigin(serial) + }) + } + + function doUpdateDevicesCurrentGroupDates(group) { + if (apiutil.isOriginGroup(group.class)) { + return Promise.map(group.devices, function(serial) { + return dbapi.loadDeviceBySerial(serial).then(function(device) { + return device.group.id === group.id ? + doUpdateDevicesCurrentGroup(group, [serial]) : + false + }) + }) + } + else { + return Promise.map(group.devices, function(serial) { + return doUpdateDevicesCurrentGroup(group, [serial]) + }) + } + } + + function treatGroupUsersChange(group, users, isActive, isAddedUser) { + if (isActive) { + return Promise.map(users, function(email) { + return Promise.map(group.devices, function(serial) { + return dbapi.loadDeviceBySerial(serial).then(function(device) { + if (device && device.group.id === group.id) { + if (!isAddedUser && device.owner && device.owner.email === email) { + return new Promise(function(resolve) { + let messageListener + const responseTimer = setTimeout(function() { + channelRouter.removeListener(wireutil.global, messageListener) + resolve(serial) + }, 5000) + + messageListener = wirerouter() + .on(wire.LeaveGroupMessage, function(channel, message) { + if (message.serial === serial && + message.owner.email === email) { + clearTimeout(responseTimer) + channelRouter.removeListener(wireutil.global, messageListener) + resolve(serial) + } + }) + .handler() + + channelRouter.on(wireutil.global, messageListener) + sendReleaseDeviceControl(serial, device.channel) + }) + } + return serial + } + return false + }) + }) + .then(function(devices) { + sendGroupUsersChange( + group, [email], _.without(devices, false), isAddedUser, 'GroupUser(s)Updated') + }) + }) + } + else { + return sendGroupUsersChange(group, users, [], isAddedUser, 'GroupUser(s)Updated') + } + } + + function treatGroupDevicesChange(oldGroup, group, devices, isAddedDevice) { + if (isAddedDevice) { + return doUpdateDevicesCurrentGroup(group, devices) + } + else { + return doUpdateDevicesCurrentGroupFromOrigin(devices) + .then(function() { + if (group === null) { + sendGroupUsersChange(oldGroup, oldGroup.users, [], false, 'GroupDeletedLater') + } + }) + } + } + + function treatGroupDeletion(group) { + if (apiutil.isOriginGroup(group.class)) { + return dbapi.getRootGroup().then(function(rootGroup) { + return Promise.map(group.devices, function(serial) { + return dbapi.updateDeviceOriginGroup(serial, rootGroup) + }) + .then(function() { + sendGroupUsersChange(group, group.users, [], false, 'GroupDeletedLater') + }) + }) + } + else { + return sendGroupUsersChange(group, group.users, [], false, 'GroupDeleted') + } + } + + + db.run(r + .table('groups') + .pluck( + 'id' + , 'name' + , 'class' + , 'privilege' + , 'owner' + , 'dates' + , 'duration' + , 'repetitions' + , 'devices' + , 'users' + , 'state' + , 'isActive' + , 'ticket' + ) + .changes(), function(err, cursor) { + if (err) { + throw err + } + return cursor + }) + .then(function(cursor) { + cursor.each(function(err, data) { + let users, devices, isBecomeActive, isBecomeUnactive, isActive + , isAddedUser, isAddedDevice, isUpdatedDeviceOriginGroup, isChangedDates + + if (err) { + throw err + } + if (data.old_val === null) { + sendGroupChange( + data.new_val + , data.new_val.users + , false + , false + , false + , [] + , false + , [] + , 'created' + ) + return sendGroupUsersChange( + data.new_val + , data.new_val.users + , data.new_val.devices + , true + , 'GroupCreated' + ) + } + + if (data.new_val === null) { + sendGroupChange( + data.old_val + , data.old_val.users + , false + , false + , false + , [] + , false + , [] + , 'deleted' + ) + + users = data.old_val.users + devices = data.old_val.devices + isChangedDates = false + isActive = data.old_val.isActive + isBecomeActive = isBecomeUnactive = false + isAddedUser = isAddedDevice = false + isUpdatedDeviceOriginGroup = false + } + else { + users = _.xor(data.new_val.users, data.old_val.users) + devices = _.xor(data.new_val.devices, data.old_val.devices) + isChangedDates = + data.old_val.dates.length !== data.new_val.dates.length || + data.old_val.dates[0].start.getTime() !== + data.new_val.dates[0].start.getTime() || + data.old_val.dates[0].stop.getTime() !== + data.new_val.dates[0].stop.getTime() + isActive = data.new_val.isActive + isBecomeActive = !data.old_val.isActive && data.new_val.isActive + isBecomeUnactive = data.old_val.isActive && !data.new_val.isActive + isAddedUser = data.new_val.users.length > data.old_val.users.length + isAddedDevice = data.new_val.devices.length > data.old_val.devices.length + isUpdatedDeviceOriginGroup = + data.new_val.ticket !== null && + (data.old_val.ticket === null || + data.new_val.ticket.signature !== data.old_val.ticket.signature) + + if (!isUpdatedDeviceOriginGroup) { + sendGroupChange( + data.new_val + , _.union(data.old_val.users, data.new_val.users) + , isChangedDates + , data.old_val.class !== data.new_val.class + , isAddedUser + , users + , isAddedDevice + , devices + , 'updated' + ) + } + } + + if (isUpdatedDeviceOriginGroup) { + return doUpdateDeviceOriginGroup(data.new_val) + } + else if (isBecomeActive && data.new_val.devices.length) { + return doUpdateDevicesCurrentGroup(data.new_val, data.new_val.devices) + } + else if (isBecomeUnactive && data.new_val.devices.length) { + return doUpdateDevicesCurrentGroupFromOrigin(data.new_val.devices) + } + else if (devices.length && isActive && !apiutil.isOriginGroup(data.old_val.class)) { + return treatGroupDevicesChange(data.old_val, data.new_val, devices, isAddedDevice) + } + else if (data.new_val === null) { + return treatGroupDeletion(data.old_val) + } + else if (isChangedDates && isActive) { + return doUpdateDevicesCurrentGroupDates(data.new_val) + } + else if (users.length) { + return treatGroupUsersChange(data.old_val, users, isActive, isAddedUser) + } + return true + }) + }) + .catch(function(err) { + log.error('An error occured during GROUPS table watching', err.stack) + }) +} diff --git a/lib/units/groups-engine/watchers/users.js b/lib/units/groups-engine/watchers/users.js new file mode 100644 index 00000000..ce23553c --- /dev/null +++ b/lib/units/groups-engine/watchers/users.js @@ -0,0 +1,94 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const timeutil = require('../../../util/timeutil') +const r = require('rethinkdb') +const _ = require('lodash') +const logger = require('../../../util/logger') +const wireutil = require('../../../wire/util') +const wire = require('../../../wire') +const db = require('../../../db') + +module.exports = function(pushdev) { + const log = logger.createLogger('watcher-users') + + function sendUserChange(user, isAddedGroup, groups, action, targets) { + pushdev.send([ + wireutil.global + , wireutil.envelope( + new wire.UserChangeMessage( + user + , isAddedGroup + , groups + , action + , targets + , timeutil.now('nano'))) + ]) + } + + db.run(r + .table('users') + .pluck( + 'email' + , 'name' + , 'privilege' + , {groups: ['quotas', 'subscribed'] + }) + .changes(), function(err, cursor) { + if (err) { + throw err + } + return cursor + }) + .then(function(cursor) { + cursor.each(function(err, data) { + if (err) { + throw err + } + if (data.old_val === null) { + sendUserChange(data.new_val, false, [], 'created', ['settings']) + } + else if (data.new_val === null) { + sendUserChange(data.old_val, false, [], 'deleted', ['settings']) + } + else { + const targets = [] + + if (!_.isEqual( + data.new_val.groups.quotas.allocated + , data.old_val.groups.quotas.allocated)) { + targets.push('settings') + targets.push('view') + } + else if (!_.isEqual( + data.new_val.groups.quotas.consumed + , data.old_val.groups.quotas.consumed)) { + targets.push('view') + } + else if (data.new_val.groups.quotas.defaultGroupsNumber !== + data.old_val.groups.quotas.defaultGroupsNumber || + data.new_val.groups.quotas.defaultGroupsDuration !== + data.old_val.groups.quotas.defaultGroupsDuration || + data.new_val.groups.quotas.defaultGroupsRepetitions !== + data.old_val.groups.quotas.defaultGroupsRepetitions || + data.new_val.groups.quotas.repetitions !== + data.old_val.groups.quotas.repetitions || + !_.isEqual(data.new_val.groups.subscribed, data.old_val.groups.subscribed)) { + targets.push('settings') + } + if (targets.length) { + sendUserChange( + data.new_val + , data.new_val.groups.subscribed.length > data.old_val.groups.subscribed.length + , _.xor(data.new_val.groups.subscribed, data.old_val.groups.subscribed) + , 'updated' + , targets) + } + } + }) + }) + .catch(function(err) { + log.error('An error occured during USERS table watching', err.stack) + }) +} diff --git a/lib/units/processor/index.js b/lib/units/processor/index.js index d2a866c1..a2c89952 100644 --- a/lib/units/processor/index.js +++ b/lib/units/processor/index.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var Promise = require('bluebird') var logger = require('../../util/logger') @@ -55,17 +59,70 @@ module.exports = db.ensureConnectivity(function(options) { }) devDealer.on('message', wirerouter() + .on(wire.UpdateAccessTokenMessage, function(channel, message, data) { + appDealer.send([channel, data]) + }) + .on(wire.DeleteUserMessage, function(channel, message, data) { + appDealer.send([channel, data]) + }) + .on(wire.DeviceChangeMessage, function(channel, message, data) { + appDealer.send([channel, data]) + }) + .on(wire.UserChangeMessage, function(channel, message, data) { + appDealer.send([channel, data]) + }) + .on(wire.GroupChangeMessage, function(channel, message, data) { + appDealer.send([channel, data]) + }) + .on(wire.DeviceGroupChangeMessage, function(channel, message, data) { + appDealer.send([channel, data]) + }) + .on(wire.GroupUserChangeMessage, function(channel, message, data) { + appDealer.send([channel, data]) + }) // Initial device message - .on(wire.DeviceIntroductionMessage, function(channel, message, data) { + .on(wire.DeviceIntroductionMessage, function(channel, message) { dbapi.saveDeviceInitialState(message.serial, message) - .then(function() { + .then(function(device) { devDealer.send([ message.provider.channel , wireutil.envelope(new wire.DeviceRegisteredMessage( message.serial )) ]) - appDealer.send([channel, data]) + appDealer.send([ + channel + , wireutil.envelope(new wire.DeviceIntroductionMessage( + message.serial + , message.status + , new wire.ProviderMessage( + message.provider.channel + , message.provider.name + ) + , new wire.DeviceGroupMessage( + device.group.id + , device.group.name + , new wire.DeviceGroupOwnerMessage( + device.group.owner.email + , device.group.owner.name + ) + , new wire.DeviceGroupLifetimeMessage( + device.group.lifeTime.start.getTime() + , device.group.lifeTime.stop.getTime() + ) + , device.group.class + , device.group.repetitions + , device.group.originName + ) + )) + ]) + }) + .catch(function(err) { + log.error( + 'Unable to save the initial state of Device "%s"' + , message.serial + , err.stack + ) }) }) // Workerless messages diff --git a/lib/units/websocket/index.js b/lib/units/websocket/index.js index 5ed69194..51e3cbd7 100644 --- a/lib/units/websocket/index.js +++ b/lib/units/websocket/index.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var http = require('http') var events = require('events') var util = require('util') @@ -23,6 +27,8 @@ var ip = require('./middleware/remote-ip') var auth = require('./middleware/auth') var jwtutil = require('../../util/jwtutil') +const apiutil = require('../../util/apiutil') + module.exports = function(options) { var log = logger.createLogger('websocket') var server = http.createServer() @@ -118,23 +124,99 @@ module.exports = function(options) { } } + let disconnectSocket var messageListener = wirerouter() + .on(wire.UpdateAccessTokenMessage, function() { + socket.emit('user.keys.accessToken.updated') + }) + .on(wire.DeleteUserMessage, function() { + disconnectSocket(true) + }) + .on(wire.DeviceChangeMessage, function(channel, message) { + if (user.groups.subscribed.indexOf(message.device.group.origin) > -1 || + user.groups.subscribed.indexOf(message.oldOriginGroupId) > -1) { + socket.emit('user.settings.devices.' + message.action, message) + } + }) + .on(wire.UserChangeMessage, function(channel, message) { + Promise.map(message.targets, function(target) { + socket.emit('user.' + target + '.users.' + message.action, message) + }) + }) + .on(wire.GroupChangeMessage, function(channel, message) { + if (user.privilege === 'admin' || + user.email === message.group.owner.email || + !apiutil.isOriginGroup(message.group.class) && + (message.action === 'deleted' || + message.action === 'updated' && + (message.isChangedDates || message.isChangedClass || message.devices.length))) { + socket.emit('user.settings.groups.' + message.action, message) + } + if (message.subscribers.indexOf(user.email) > -1) { + socket.emit('user.view.groups.' + message.action, message) + } + }) + .on(wire.DeviceGroupChangeMessage, function(channel, message) { + if (user.groups.subscribed.indexOf(message.id) > -1) { + if (user.groups.subscribed.indexOf(message.group.id) > -1) { + socket.emit('device.updateGroupDevice', { + important: true + , data: { + serial: message.serial + , group: message.group + } + }) + } + else { + socket.emit('device.removeGroupDevices', {important: true, devices: [message.serial]}) + } + } + else if (user.groups.subscribed.indexOf(message.group.id) > -1) { + socket.emit('device.addGroupDevices', {important: true, devices: [message.serial]}) + } + }) + .on(wire.GroupUserChangeMessage, function(channel, message) { + if (message.users.indexOf(user.email) > -1) { + if (message.isAdded) { + user.groups.subscribed = _.union(user.groups.subscribed, [message.id]) + if (message.devices.length) { + socket.emit('device.addGroupDevices', {important: true, devices: message.devices}) + } + } + else { + if (message.devices.length) { + socket.emit('device.removeGroupDevices', {important: true, devices: message.devices}) + } + if (message.isDeletedLater) { + setTimeout(function() { + user.groups.subscribed = _.without(user.groups.subscribed, message.id) + }, 5000) + } + else { + user.groups.subscribed = _.without(user.groups.subscribed, message.id) + } + } + } + }) .on(wire.DeviceLogMessage, function(channel, message) { socket.emit('device.log', message) }) .on(wire.DeviceIntroductionMessage, function(channel, message) { - socket.emit('device.add', { - important: true - , data: { - serial: message.serial - , present: false - , provider: message.provider - , owner: null - , status: message.status - , ready: false - , reverseForwards: [] - } - }) + if (user.groups.subscribed.indexOf(message.group.id) > -1) { + socket.emit('device.add', { + important: true + , data: { + serial: message.serial + , present: true + , provider: message.provider + , owner: null + , status: message.status + , ready: false + , reverseForwards: [] + , group: message.group + } + }) + } }) .on(wire.DeviceReadyMessage, function(channel, message) { socket.emit('device.change', { @@ -307,6 +389,7 @@ module.exports = function(options) { joinChannel(user.group) new Promise(function(resolve) { + disconnectSocket = resolve socket.on('disconnect', resolve) // Global messages for all clients using socket.io // @@ -314,15 +397,19 @@ module.exports = function(options) { .on('device.note', function(data) { return dbapi.setDeviceNote(data.serial, data.note) .then(function() { - return dbapi.loadDevice(data.serial) + return dbapi.loadDevice(user.groups.subscribed, data.serial) }) - .then(function(device) { - if (device) { - io.emit('device.change', { - important: true - , data: { - serial: device.serial - , notes: device.notes + .then(function(cursor) { + if (cursor) { + cursor.next(function(err, device) { + if (!err) { + io.emit('device.change', { + important: true + , data: { + serial: device.serial + , notes: device.notes + } + }) } }) } @@ -364,7 +451,7 @@ module.exports = function(options) { .on('user.keys.accessToken.remove', function(data) { return dbapi.removeUserAccessToken(user.email, data.title) .then(function() { - socket.emit('user.keys.accessToken.removed', data.title) + socket.emit('user.keys.accessToken.updated') }) }) .on('user.keys.adb.add', function(data) { @@ -916,6 +1003,7 @@ module.exports = function(options) { channelRouter.removeListener(channel, messageListener) sub.unsubscribe(channel) }) + socket.disconnect(true) }) .catch(function(err) { // Cannot guarantee integrity of client @@ -923,8 +1011,7 @@ module.exports = function(options) { 'Client had an error, disconnecting due to probable loss of integrity' , err.stack ) - - socket.disconnect(true) + // move 'socket.disconnect(true)' statement to finally block instead! }) }) diff --git a/lib/util/apiutil.js b/lib/util/apiutil.js new file mode 100644 index 00000000..016e0f18 --- /dev/null +++ b/lib/util/apiutil.js @@ -0,0 +1,257 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const Promise = require('bluebird') +const _ = require('lodash') +const logger = require('./logger') +const datautil = require('./datautil') + +const apiutil = Object.create(null) +const log = logger.createLogger('api:controllers:apiutil') + +apiutil.PENDING = 'pending' +apiutil.READY = 'ready' +apiutil.WAITING = 'waiting' + +apiutil.BOOKABLE = 'bookable' +apiutil.STANDARD = 'standard' +apiutil.ONCE = 'once' +apiutil.DEBUG = 'debug' +apiutil.ORIGIN = 'origin' +apiutil.STANDARDIZABLE = 'standardizable' + +apiutil.ROOT = 'root' +apiutil.ADMIN = 'admin' +apiutil.USER = 'user' + +apiutil.FIVE_MN = 300 * 1000 +apiutil.ONE_HOUR = 3600 * 1000 +apiutil.ONE_DAY = 24 * apiutil.ONE_HOUR +apiutil.ONE_WEEK = 7 * apiutil.ONE_DAY +apiutil.ONE_MONTH = 30 * apiutil.ONE_DAY +apiutil.ONE_QUATER = 3 * apiutil.ONE_MONTH +apiutil.ONE_HALF_YEAR = 6 * apiutil.ONE_MONTH +apiutil.ONE_YEAR = 365 * apiutil.ONE_DAY + +apiutil.MAX_USER_GROUPS_NUMBER = 5 +apiutil.MAX_USER_GROUPS_DURATION = 15 * apiutil.ONE_DAY +apiutil.MAX_USER_GROUPS_REPETITIONS = 10 + +apiutil.CLASS_DURATION = { + once: Infinity +, bookable: Infinity +, standard: Infinity +, hourly: apiutil.ONE_HOUR +, daily: apiutil.ONE_DAY +, weekly: apiutil.ONE_WEEK +, monthly: apiutil.ONE_MONTH +, quaterly: apiutil.ONE_QUATER +, halfyearly: apiutil.ONE_HALF_YEAR +, yearly: apiutil.ONE_YEAR +, debug: apiutil.FIVE_MN +} + +apiutil.isOriginGroup = function(_class) { + return _class === apiutil.BOOKABLE || _class === apiutil.STANDARD +} + +apiutil.isAdminGroup = function(_class) { + return apiutil.isOriginGroup(_class) || _class === apiutil.DEBUG +} + +apiutil.internalError = function(res, ...args) { + log.error.apply(log, args) + apiutil.respond(res, 500, 'Internal Server Error') +} + +apiutil.respond = function(res, code, message, data) { + const status = code >= 200 && code < 300 + const response = { + success: status + , description: message + } + + if (data) { + for (const key in data) { + if (data.hasOwnProperty(key)) { + response[key] = data[key] + } + } + } + res.status(code).json(response) + return status +} + +apiutil.publishGroup = function(group) { +// delete group.lock + delete group.createdAt + delete group.ticket + return group +} + +apiutil.publishDevice = function(device, user) { + datautil.normalize(device, user) +// delete device.group.lock + return device +} + +apiutil.publishUser = function(user) { +// delete user.groups.lock + return user +} + +apiutil.publishAccessToken = function(token) { + delete token.email + delete token.jwt + return token +} + +apiutil.filterDevice = function(req, device) { + const fields = req.swagger.params.fields.value + + if (fields) { + return _.pick(apiutil.publishDevice(device, req.user), fields.split(',')) + } + return apiutil.publishDevice(device, req.user) +} + +apiutil.computeDuration = function(group, deviceNumber) { + return (group.devices.length + deviceNumber) * + (group.dates[0].stop - group.dates[0].start) * + (group.repetitions + 1) +} + +apiutil.lightComputeStats = function(res, stats) { + if (stats.locked) { + apiutil.respond(res, 503, 'Server too busy, please try again later') + return Promise.reject('busy') + } + return 'not found' +} + +apiutil.computeStats = function(res, stats, objectName, ...lock) { + if (!stats.replaced) { + if (stats.skipped) { + return apiutil.respond(res, 404, `Not Found (${objectName})`) + } + if (stats.locked) { + return apiutil.respond(res, 503, 'Server too busy, please try again later') + } + return apiutil.respond(res, 403, `Forbidden (${objectName})`) + } + if (lock.length) { + lock[0][objectName] = stats.changes[0].new_val + } + return true +} + +apiutil.lockResult = function(stats) { + const result = {status: false, data: stats} + + if (stats.replaced || stats.skipped) { + result.status = true + result.data.locked = false + } + else { + result.data.locked = true + } + return result +} + +apiutil.lockDeviceResult = function(stats, fn, groups, serial) { + const result = apiutil.lockResult(stats) + if (!result.status) { + return fn(groups, serial).then(function(devices) { + if (!devices.length) { + result.data.locked = false + result.status = true + } + return result + }) + } + return result +} + +apiutil.setIntervalWrapper = function(fn, numTimes, delay) { + return fn().then(function(result) { + if (result.status) { + return result.data + } + return new Promise(function(resolve, reject) { + let counter = 0 + const interval = setInterval(function() { + return fn().then(function(result) { + if (result.status || ++counter === numTimes) { + if (!result.status && counter === numTimes) { + log.debug('%s() failed %s times in a loop!', fn.name, counter) + } + clearInterval(interval) + resolve(result.data) + } + }) + .catch(function(err) { + clearInterval(interval) + reject(err) + }) + }, delay) + }) + }) +} + +apiutil.redirectApiWrapper = function(field, fn, req, res) { + if (typeof req.body === 'undefined') { + req.body = {} + } + req.body[field + 's'] = req.swagger.params[field].value + req.swagger.params.redirected = {value: true} + fn(req, res) +} + +apiutil.computeGroupDates = function(lifeTime, _class, repetitions) { + const dates = new Array(lifeTime) + + for(let repetition = 1 + , currentLifeTime = { + start: new Date(lifeTime.start.getTime()) + , stop: new Date(lifeTime.stop.getTime()) + } + ; repetition <= repetitions + ; repetition++) { + currentLifeTime.start = new Date( + currentLifeTime.start.getTime() + + apiutil.CLASS_DURATION[_class] + ) + currentLifeTime.stop = new Date( + currentLifeTime.stop.getTime() + + apiutil.CLASS_DURATION[_class] + ) + dates.push({ + start: new Date(currentLifeTime.start.getTime()) + , stop: new Date(currentLifeTime.stop.getTime()) + }) + } + return dates +} + +apiutil.checkBodyParameter = function(body, parameter) { + return typeof body !== 'undefined' && typeof body[parameter] !== 'undefined' +} + +apiutil.getBodyParameter = function(body, parameter) { + let undef + + return apiutil.checkBodyParameter(body, parameter) ? body[parameter] : undef +} + +apiutil.checkQueryParameter = function(parameter) { + return typeof parameter !== 'undefined' && typeof parameter.value !== 'undefined' +} + +apiutil.getQueryParameter = function(parameter) { + let undef + + return apiutil.checkQueryParameter(parameter) ? parameter.value : undef +} + +module.exports = apiutil diff --git a/lib/util/datautil.js b/lib/util/datautil.js index d1cfaa27..eb691d74 100644 --- a/lib/util/datautil.js +++ b/lib/util/datautil.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var deviceData = require('stf-device-db') var browserData = require('stf-browser-db') @@ -41,13 +45,14 @@ datautil.applyBrowsers = function(device) { } datautil.applyOwner = function(device, user) { - device.using = !!device.owner && device.owner.email === user.email + device.using = !!device.owner && + (device.owner.email === user.email || user.privilege === 'admin') return device } // Only owner can see this information datautil.applyOwnerOnlyInfo = function(device, user) { - if (device.owner && device.owner.email === user.email) { + if (device.owner && (device.owner.email === user.email || user.privilege === 'admin')) { // No-op } else { diff --git a/lib/util/deviceutil.js b/lib/util/deviceutil.js index 7b125d9a..257d9249 100644 --- a/lib/util/deviceutil.js +++ b/lib/util/deviceutil.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var logger = require('./logger') var log = logger.createLogger('util:deviceutil') @@ -8,7 +12,7 @@ deviceutil.isOwnedByUser = function(device, user) { return device.present && device.ready && device.owner && - device.owner.email === user.email && + (device.owner.email === user.email || user.privilege === 'admin') && device.using } diff --git a/lib/util/fakedevice.js b/lib/util/fakedevice.js index 966f2d27..04a299d9 100644 --- a/lib/util/fakedevice.js +++ b/lib/util/fakedevice.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var util = require('util') var uuid = require('uuid') @@ -7,10 +11,8 @@ var dbapi = require('../db/api') var devices = require('stf-device-db/dist/devices-latest') module.exports.generate = function(wantedModel) { - var serial = util.format( - 'fake-%s' - , uuid.v4(null, new Buffer(16)).toString('base64') - ) + // no base64 because some characters as '=' or '/' are not compatible through API (delete devices) + const serial = 'fake-' + util.format('%s', uuid.v4()).replace(/-/g, '') return dbapi.saveDeviceInitialState(serial, { provider: { @@ -28,7 +30,7 @@ module.exports.generate = function(wantedModel) { , model: model , version: '4.1.2' , abi: 'armeabi-v7a' - , sdk: 8 + Math.floor(Math.random() * 12) + , sdk: (8 + Math.floor(Math.random() * 12)).toString() // string required! , display: { density: 3 , fps: 60 @@ -49,6 +51,8 @@ module.exports.generate = function(wantedModel) { , phoneNumber: '0000000000' } , product: model + , cpuPlatform: 'msm8996' + , openGLESVersion: '3.1' }) }) .then(function() { diff --git a/lib/util/fakegroup.js b/lib/util/fakegroup.js new file mode 100644 index 00000000..00ad6f20 --- /dev/null +++ b/lib/util/fakegroup.js @@ -0,0 +1,42 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const util = require('util') +const uuid = require('uuid') +const dbapi = require('../db/api') +const apiutil = require('./apiutil') + +module.exports.generate = function() { + return dbapi.getRootGroup().then(function(rootGroup) { + const now = Date.now() + + return dbapi.createUserGroup({ + name: 'fakegroup-' + util.format('%s', uuid.v4()).replace(/-/g, '') + , owner: { + email: rootGroup.owner.email + , name: rootGroup.owner.name + } + , privilege: apiutil.ADMIN + , class: apiutil.BOOKABLE + , repetitions: 0 + , isActive: true + , dates: apiutil.computeGroupDates( + { + start: new Date(now) + , stop: new Date(now + apiutil.ONE_YEAR) + } + , apiutil.BOOKABLE + , 0 + ) + , duration: 0 + , state: apiutil.READY + }) + .then(function(group) { + if (group) { + return group.id + } + throw new Error('Forbidden (groups number quota is reached)') + }) + }) +} diff --git a/lib/util/fakeuser.js b/lib/util/fakeuser.js new file mode 100644 index 00000000..8ca850da --- /dev/null +++ b/lib/util/fakeuser.js @@ -0,0 +1,14 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const util = require('util') +const uuid = require('uuid') +const dbapi = require('../db/api') + +module.exports.generate = function() { + const name = 'fakeuser-' + util.format('%s', uuid.v4()).replace(/-/g, '') + const email = name + '@openstf.com' + + return dbapi.createUser(email, name, '127.0.0.1').return(email) +} diff --git a/lib/util/lockutil.js b/lib/util/lockutil.js new file mode 100644 index 00000000..1e2c77e7 --- /dev/null +++ b/lib/util/lockutil.js @@ -0,0 +1,69 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const apiutil = require('./apiutil') +const dbapi = require('../db/api') + +const lockutil = Object.create(null) + +lockutil.unlockDevice = function(lock) { + if (lock.device) { + dbapi.unlockDevice(lock.device.serial) + } +} + +lockutil.lockUser = function(email, res, lock) { + return dbapi.lockUser(email) + .then(function(stats) { + return apiutil.computeStats(res, stats, 'user', lock) + }) +} + +lockutil.unlockUser = function(lock) { + if (lock.user) { + dbapi.unlockUser(lock.user.email) + } +} + +lockutil.lockGroupAndUser = function(req, res, lock) { + return lockutil.lockGroup(req, res, lock).then(function(lockingSuccessed) { + return lockingSuccessed ? + lockutil.lockUser(req.user.email, res, lock) : + false + }) +} + +lockutil.unlockGroupAndUser = function(lock) { + lockutil.unlockGroup(lock) + lockutil.unlockUser(lock) +} + +lockutil.lockGroup = function(req, res, lock) { + const id = req.swagger.params.id.value + const email = req.user.email + + return dbapi.lockGroupByOwner(email, id).then(function(stats) { + return apiutil.computeStats(res, stats, 'group', lock) + }) +} + +lockutil.unlockGroup = function(lock) { + if (lock.group) { + dbapi.unlockGroup(lock.group.id) + } +} + +lockutil.unlockGroupAndDevice = function(lock) { + lockutil.unlockGroup(lock) + lockutil.unlockDevice(lock) +} + +lockutil.lockGenericDevice = function(req, res, lock, lockDevice) { + return lockDevice(req.user.groups.subscribed, req.swagger.params.serial.value) + .then(function(stats) { + return apiutil.computeStats(res, stats, 'device', lock) + }) +} + +module.exports = lockutil diff --git a/lib/util/timeutil.js b/lib/util/timeutil.js new file mode 100644 index 00000000..98992389 --- /dev/null +++ b/lib/util/timeutil.js @@ -0,0 +1,22 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const timeutil = Object.create(null) + +timeutil.now = function(unit) { + const hrTime = process.hrtime() + + switch (unit) { + case 'milli': + return hrTime[0] * 1000 + hrTime[1] / 1000000 + case 'micro': + return hrTime[0] * 1000000 + hrTime[1] / 1000 + case 'nano': + return hrTime[0] * 1000000000 + hrTime[1] + default: + return hrTime[0] * 1000000000 + hrTime[1] + } +} + +module.exports = timeutil diff --git a/lib/wire/wire.proto b/lib/wire/wire.proto index 2b2c60ca..57ad77bb 100644 --- a/lib/wire/wire.proto +++ b/lib/wire/wire.proto @@ -1,3 +1,7 @@ +// +// Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + // Message wrapper enum MessageType { @@ -78,6 +82,159 @@ enum MessageType { FileSystemGetMessage = 82; ConnectStartedMessage = 92; ConnectStoppedMessage = 93; + GroupUserChangeMessage = 1200; + DeviceGroupChangeMessage = 1201; + DeviceOriginGroupMessage = 1202; + DeleteUserMessage = 1203; + UpdateAccessTokenMessage = 1204; + GroupChangeMessage = 1205; + UserChangeMessage = 1206; + DeviceChangeMessage = 1207; +} + +message UpdateAccessTokenMessage { +} + +message DeleteUserMessage { + required string email = 1; +} + +message DeviceOriginGroupMessage { + required string signature = 1; +} + +message UserQuotasDetailField { + required double duration = 1; + required uint32 number = 2; +} + +message UserQuotasField { + required UserQuotasDetailField allocated = 1; + required UserQuotasDetailField consumed = 2; + required uint32 defaultGroupsDuration = 3; + required uint32 defaultGroupsNumber = 4; + required uint32 defaultGroupsRepetitions = 5; + required uint32 repetitions = 6; +} + +message UserGroupsField { + required UserQuotasField quotas = 1; + repeated string subscribed = 2; +} + +message UserField { + required string email = 1; + required string name = 2; + required string privilege = 3; + required UserGroupsField groups = 4; +} + +message UserChangeMessage { + required UserField user = 1; + required bool isAddedGroup = 2; + repeated string groups = 3; + required string action = 4; + repeated string targets = 5; + required double timeStamp = 6; +} + +message DeviceNetworkField { + optional string type = 1; + optional string subtype = 2; +} + +message DeviceDisplayField { + optional uint32 height = 1; + optional uint32 width = 2; +} + +message DevicePhoneField { + optional string imei = 1; +} + +message DeviceProviderField { + optional string name = 1; +} + +message DeviceGroupField { + optional string origin = 1; + optional string originName = 2; +} + +message DeviceField { + required string serial = 1; + optional string model = 2; + optional string version = 3; + optional string operator = 4; + optional DeviceNetworkField network = 5; + optional DeviceDisplayField display = 6; + optional string manufacturer = 7; + optional string sdk = 8; + optional string abi = 9; + optional string cpuPlatform = 10; + optional string openGLESVersion = 11; + optional DevicePhoneField phone = 12; + optional DeviceProviderField provider = 13; + optional DeviceGroupField group = 14; +} + +message DeviceChangeMessage { + required DeviceField device = 1; + required string action = 2; + required string oldOriginGroupId = 3; + required double timeStamp = 4; +} + +message GroupDateField { + required string start = 1; + required string stop = 2; +} + +message GroupOwnerField { + required string email = 1; + required string name = 2; +} + +message GroupField { + required string id = 1; + required string name = 2; + required string class = 3; + required string privilege = 4; + required GroupOwnerField owner = 5; + repeated GroupDateField dates = 6; + required uint32 duration = 7; + required uint32 repetitions = 8; + repeated string devices = 9; + repeated string users = 10; + required string state = 11; + required bool isActive = 12; +} + +message GroupChangeMessage { + required GroupField group = 1; + required string action = 2; + repeated string subscribers = 3; + required bool isChangedDates = 4; + required bool isChangedClass = 5; + required bool isAddedUser = 6; + repeated string users = 7; + required bool isAddedDevice = 8; + repeated string devices = 9; + required double timeStamp = 10; +} + +message DeviceGroupChangeMessage { + required string id = 1; + required DeviceGroupMessage group = 2; + required string serial = 3; +} + +message GroupUserChangeMessage { + repeated string users = 1; + required bool isAdded = 2; + required string id = 3; + required bool isDeletedLater = 4; + repeated string devices = 5; } message ConnectStartedMessage { @@ -132,6 +289,26 @@ message DeviceLogMessage { // Introductions +message DeviceGroupOwnerMessage { + required string email = 1; + required string name = 2; +} + +message DeviceGroupLifetimeMessage { + required double start = 1; + required double stop = 2; +} + +message DeviceGroupMessage { + required string id = 1; + required string name = 2; + required DeviceGroupOwnerMessage owner = 3; + required DeviceGroupLifetimeMessage lifeTime = 4; + required string class = 5; + required uint32 repetitions = 6; + required string originName = 7; +} + message ProviderMessage { required string channel = 1; required string name = 2; @@ -145,6 +322,7 @@ message DeviceIntroductionMessage { required string serial = 1; required DeviceStatus status = 2; required ProviderMessage provider = 3; + optional DeviceGroupMessage group = 4; } message DeviceRegisteredMessage { diff --git a/res/app/app.js b/res/app/app.js index 5a3a4b77..f5073847 100644 --- a/res/app/app.js +++ b/res/app/app.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + require.ensure([], function(require) { require('angular') require('angular-route') @@ -10,13 +14,15 @@ require.ensure([], function(require) { require('angular-hotkeys').name, require('./layout').name, require('./device-list').name, + require('./group-list').name, require('./control-panes').name, require('./menu').name, require('./settings').name, require('./docs').name, require('./user').name, require('./../common/lang').name, - require('stf/standalone').name + require('stf/standalone').name, + require('./group-list').name ]) .config(function($routeProvider, $locationProvider) { $locationProvider.hashPrefix('!') diff --git a/res/app/components/stf/column-choice/column-choice-directive.js b/res/app/components/stf/column-choice/column-choice-directive.js new file mode 100644 index 00000000..997ce854 --- /dev/null +++ b/res/app/components/stf/column-choice/column-choice-directive.js @@ -0,0 +1,15 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function() { + return { + restrict: 'E', + scope: { + buttonStyle: '@?', + columnData: '=', + resetData: '&' + }, + template: require('./column-choice.pug'), + } +} diff --git a/res/app/components/stf/column-choice/column-choice.css b/res/app/components/stf/column-choice/column-choice.css new file mode 100644 index 00000000..b27a8f39 --- /dev/null +++ b/res/app/components/stf/column-choice/column-choice.css @@ -0,0 +1,23 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +.stf-column-choice .stf-column-customize { + white-space: nowrap; + padding: 10px; + padding-bottom: 0; + column-count: 2; + -moz-column-count: 2; + -webkit-column-count: 2; + max-width: 800px; +} + +.stf-column-choice .stf-column-customize .checkbox { + margin-bottom: 10px; +} + +.stf-column-choice .stf-column-customize .checkbox-label { + margin-left: 10px; +} + + diff --git a/res/app/components/stf/column-choice/column-choice.pug b/res/app/components/stf/column-choice/column-choice.pug new file mode 100644 index 00000000..1dd5b6a7 --- /dev/null +++ b/res/app/components/stf/column-choice/column-choice.pug @@ -0,0 +1,24 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.stf-column-choice + .btn-group(uib-dropdown auto-close='outsideClick') + button.btn.btn-sm.btn-primary-outline( + style='margin-top: 5px; {{buttonStyle}}' + type='button' + uib-dropdown-toggle) + i.fa.fa-columns + span(translate) Customize + ul.dropdown-menu.pointer.stf-column-customize( + uib-dropdown-menu role='menu' + ng-click='$event.stopPropagation()') + li(ng-repeat='column in columnData') + label.checkbox.pointer + input(type='checkbox' ng-model='column.selected') + span.checkbox-label(ng-bind-template='{{::column.name | translate}}') + li + button.btn.btn-xs.btn-danger-outline.checkbox(ng-click='resetData()') + i.fa.fa-trash-o + span(ng-bind='"Reset"|translate') + diff --git a/res/app/components/stf/column-choice/index.js b/res/app/components/stf/column-choice/index.js new file mode 100644 index 00000000..33a2ddd6 --- /dev/null +++ b/res/app/components/stf/column-choice/index.js @@ -0,0 +1,12 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +require('./column-choice.css') + +module.exports = angular.module('stf.column-choice', [ + require('stf/common-ui').name +]) + .directive('stfColumnChoice', require('./column-choice-directive')) + + diff --git a/res/app/components/stf/common-ui/index.js b/res/app/components/stf/common-ui/index.js index 6264cf60..8b468bce 100644 --- a/res/app/components/stf/common-ui/index.js +++ b/res/app/components/stf/common-ui/index.js @@ -1,4 +1,9 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = angular.module('stf/common-ui', [ + require('./pagination').name, require('./safe-apply').name, require('./clear-button').name, require('./filter-button').name, diff --git a/res/app/components/stf/common-ui/modals/generic-modal/generic-modal-service.js b/res/app/components/stf/common-ui/modals/generic-modal/generic-modal-service.js new file mode 100644 index 00000000..166527c1 --- /dev/null +++ b/res/app/components/stf/common-ui/modals/generic-modal/generic-modal-service.js @@ -0,0 +1,38 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = + function GenericModalServiceFactory($uibModal) { + const service = {} + + const ModalInstanceCtrl = function($scope, $uibModalInstance, data) { + $scope.data = data + + $scope.ok = function() { + $uibModalInstance.close(true) + } + + $scope.cancel = function() { + $uibModalInstance.dismiss('cancel') + } + } + + service.open = function(data) { + var modalInstance = $uibModal.open({ + template: require('./generic-modal.pug'), + controller: ModalInstanceCtrl, + size: data.size, + animation: true, + resolve: { + data: function() { + return data + } + } + }) + + return modalInstance.result + } + + return service + } diff --git a/res/app/components/stf/common-ui/modals/generic-modal/generic-modal-spec.js b/res/app/components/stf/common-ui/modals/generic-modal/generic-modal-spec.js new file mode 100644 index 00000000..6ce25b01 --- /dev/null +++ b/res/app/components/stf/common-ui/modals/generic-modal/generic-modal-spec.js @@ -0,0 +1,15 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +describe('GenericModalService', function() { + + beforeEach(angular.mock.module(require('./').name)) + + it('should ...', inject(function() { + + //expect(FatalMessageService.doSomething()).toEqual('something'); + + })) + +}) diff --git a/res/app/components/stf/common-ui/modals/generic-modal/generic-modal.pug b/res/app/components/stf/common-ui/modals/generic-modal/generic-modal.pug new file mode 100644 index 00000000..ca43b2aa --- /dev/null +++ b/res/app/components/stf/common-ui/modals/generic-modal/generic-modal.pug @@ -0,0 +1,36 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.stf-generic-modal.stf-modal + .modal-header + h4.modal-title.text-warning(ng-if="data.type === 'Warning'") + i.fa.fa-warning + .button-spacer + span(translate) {{data.type}} + + h4.modal-title.text-info(ng-if="data.type === 'Information'") + i.fa.fa-info-circle + .button-spacer + span(translate) {{data.type}} + + h4.modal-title.text-danger(ng-if="data.type === 'Error'") + i.fa.fa-times-circle + .button-spacer + span(translate) {{data.type}} + + .modal-body + label.control-label + span(translate) {{data.message}} + + .modal-footer + button.btn.btn-primary( + type='button' + ng-click='ok()') + span(translate) OK + + button.btn.btn-warning( + type='button' + ng-if='data.cancel' + ng-click='cancel()') + span(translate) Cancel diff --git a/res/app/components/stf/common-ui/modals/generic-modal/index.js b/res/app/components/stf/common-ui/modals/generic-modal/index.js new file mode 100644 index 00000000..529fb6bd --- /dev/null +++ b/res/app/components/stf/common-ui/modals/generic-modal/index.js @@ -0,0 +1,8 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = angular.module('stf.generic-modal', [ + require('stf/common-ui/modals/common').name +]) + .factory('GenericModalService', require('./generic-modal-service')) diff --git a/res/app/components/stf/common-ui/modals/index.js b/res/app/components/stf/common-ui/modals/index.js index 5b09337c..9ece5cc8 100644 --- a/res/app/components/stf/common-ui/modals/index.js +++ b/res/app/components/stf/common-ui/modals/index.js @@ -1,4 +1,9 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = angular.module('stf.modals', [ + require('./generic-modal').name, require('./fatal-message').name, require('./socket-disconnected').name, require('./version-update').name, diff --git a/res/app/components/stf/common-ui/pagination/index.js b/res/app/components/stf/common-ui/pagination/index.js new file mode 100644 index 00000000..f8609619 --- /dev/null +++ b/res/app/components/stf/common-ui/pagination/index.js @@ -0,0 +1,12 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +require('./pagination.css') + +module.exports = angular.module('stf.pagination', [ +]) + .filter('pagedObjectsFilter', require('./pagination-filter')) + .directive('stfPager', require('./pagination-directive')) + .factory('ItemsPerPageOptionsService', require('./pagination-service')) + diff --git a/res/app/components/stf/common-ui/pagination/pagination-directive.js b/res/app/components/stf/common-ui/pagination/pagination-directive.js new file mode 100644 index 00000000..3199179a --- /dev/null +++ b/res/app/components/stf/common-ui/pagination/pagination-directive.js @@ -0,0 +1,24 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function() { + return { + restrict: 'E', + scope: { + tooltipLabel: '@', + iconStyle: '@?', + itemsSearchStyle: '@?', + itemsSearch: '=', + itemsPerPageOptions: '<', + itemsPerPage: '=', + totalItems: '<', + totalItemsStyle: '@?', + currentPage: '=' + }, + template: require('./pagination.pug'), + link: function(scope, element, attrs) { + scope.currentPage = 1 + } + } +} diff --git a/res/app/components/stf/common-ui/pagination/pagination-filter.js b/res/app/components/stf/common-ui/pagination/pagination-filter.js new file mode 100644 index 00000000..23c2337e --- /dev/null +++ b/res/app/components/stf/common-ui/pagination/pagination-filter.js @@ -0,0 +1,16 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function() { + return function(objects, scope, currentPage, maxItems, searchItems) { + scope[searchItems] = objects + if (scope[maxItems].value === 0) { + return objects + } + return objects.slice( + (scope[currentPage] - 1) * scope[maxItems].value + , scope[currentPage] * scope[maxItems].value + ) + } +} diff --git a/res/app/components/stf/common-ui/pagination/pagination-service.js b/res/app/components/stf/common-ui/pagination/pagination-service.js new file mode 100644 index 00000000..1b4f7d72 --- /dev/null +++ b/res/app/components/stf/common-ui/pagination/pagination-service.js @@ -0,0 +1,21 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function ItemsPerPageOptionsServiceFactory() { + const service = [ + {name: '1', value: 1} + , {name: '5', value: 5} + , {name: '10', value: 10} + , {name: '20', value: 20} + , {name: '50', value: 50} + , {name: '100', value: 100} + , {name: '200', value: 200} + , {name: '500', value: 500} + , {name: '1000', value: 1000} + , {name: '*', value: 0} + ] + + return service +} + diff --git a/res/app/components/stf/common-ui/pagination/pagination.css b/res/app/components/stf/common-ui/pagination/pagination.css new file mode 100644 index 00000000..cd1fa8d8 --- /dev/null +++ b/res/app/components/stf/common-ui/pagination/pagination.css @@ -0,0 +1,4 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + diff --git a/res/app/components/stf/common-ui/pagination/pagination.pug b/res/app/components/stf/common-ui/pagination/pagination.pug new file mode 100644 index 00000000..1f3d8e58 --- /dev/null +++ b/res/app/components/stf/common-ui/pagination/pagination.pug @@ -0,0 +1,34 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.input-group(style='margin-right: 5px; {{itemsSearchStyle}}' class='{{itemsSearchStyle}}') + .input-group-addon.input-sm + i.glyphicon.glyphicon-search( + class='{{iconStyle}}' + uib-tooltip='{{tooltipLabel}}' + tooltip-placement='auto top-right' + tooltip-popup-delay='500') + input.form-control.input-sm(type='text' placeholder='Search' ng-model='itemsSearch') + +select.custon-select.form-control.input-sm( + ng-model='itemsPerPage' + ng-options='option as option.name for option in itemsPerPageOptions track by option.value') + +uib-pagination( + style='vertical-align: middle; width: -moz-max-content' + total-items='totalItems' + items-per-page='itemsPerPage.value' + class='pagination-sm' + max-size='1' + boundary-links='true' + boundary-link-numbers='false' + previous-text='<' next-text='>' first-text='First' last-text='Last' + rotate='true' + ng-model='currentPage') + +button.btn.btn-sm.btn-info( + type='button' + class='{{totalItemsStyle}}' + style='pointer-events: none') + span {{totalItems}} diff --git a/res/app/components/stf/device/device-service.js b/res/app/components/stf/device/device-service.js index a5672a46..2f609a00 100644 --- a/res/app/components/stf/device/device-service.js +++ b/res/app/components/stf/device/device-service.js @@ -1,6 +1,11 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var oboe = require('oboe') var _ = require('lodash') var EventEmitter = require('eventemitter3') +let Promise = require('bluebird') module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceService) { var deviceService = {} @@ -93,6 +98,12 @@ module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceServi if (index >= 0) { devices.splice(index, 1) delete devicesBySerial[data.serial] + for (let serial in devicesBySerial) { + if (devicesBySerial[serial] > index) { + devicesBySerial[serial]-- + } + } + sync(data) this.emit('remove', data) } }.bind(this) @@ -131,6 +142,8 @@ module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceServi } notify(event) } + + /** code removed to avoid to show forbidden devices in user view! else { if (options.filter(event.data)) { insert(event.data) @@ -139,6 +152,7 @@ module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceServi notify(event) } } + **/ } scopedSocket.on('device.add', addListener) @@ -153,6 +167,43 @@ module.exports = function DeviceServiceFactory($http, socket, EnhanceDeviceServi } this.devices = devices + + function addGroupDevicesListener(event) { + return Promise.map(event.devices, function(serial) { + return deviceService.load(serial).then(function(device) { + return device + }) + }) + .then(function(_devices) { + _devices.forEach(function(device) { + if (device && typeof devicesBySerial[device.serial] === 'undefined') { + insert(device) + notify(event) + } + }) + }) + } + + function removeGroupDevicesListener(event) { + event.devices.forEach(function(serial) { + if (typeof devicesBySerial[serial] !== 'undefined') { + remove(devices[devicesBySerial[serial]]) + notify(event) + } + }) + } + + function updateGroupDeviceListener(event) { + let device = get(event.data) + if (device) { + modify(device, event.data) + notify(event) + } + } + + scopedSocket.on('device.addGroupDevices', addGroupDevicesListener) + scopedSocket.on('device.removeGroupDevices', removeGroupDevicesListener) + scopedSocket.on('device.updateGroupDevice', updateGroupDeviceListener) } Tracker.prototype = new EventEmitter() diff --git a/res/app/components/stf/device/enhance-device/enhance-device-service.js b/res/app/components/stf/device/enhance-device/enhance-device-service.js index b55701a1..4346dffd 100644 --- a/res/app/components/stf/device/enhance-device/enhance-device-service.js +++ b/res/app/components/stf/device/enhance-device/enhance-device-service.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = function EnhanceDeviceServiceFactory($filter, AppState) { var service = {} @@ -62,6 +66,8 @@ module.exports = function EnhanceDeviceServiceFactory($filter, AppState) { device.enhancedUserProfileUrl = enhanceUserProfileUrl(device.owner.email) device.enhancedUserName = device.owner.name || 'No name' } + + device.enhancedGroupOwnerProfileUrl = enhanceUserProfileUrl(device.group.owner.email) } function enhanceUserProfileUrl(email) { diff --git a/res/app/components/stf/devices/devices-service.js b/res/app/components/stf/devices/devices-service.js new file mode 100644 index 00000000..d1e49817 --- /dev/null +++ b/res/app/components/stf/devices/devices-service.js @@ -0,0 +1,105 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const oboe = require('oboe') + +module.exports = function DevicesServiceFactory( + $rootScope +, $http +, socket +) { + const DevicesService = {} + + function buildQueryParameters(filters) { + let query = '' + + if (filters.present !== 'Any') { + query += 'present=' + filters.present.toLowerCase() + } + if (filters.booked !== 'Any') { + query += (query === '' ? '' : '&') + 'booked=' + filters.booked.toLowerCase() + } + if (filters.annotated !== 'Any') { + query += (query === '' ? '' : '&') + 'annotated=' + filters.annotated.toLowerCase() + } + if (filters.controlled !== 'Any') { + query += (query === '' ? '' : '&') + 'controlled=' + filters.controlled.toLowerCase() + } + return query === '' ? query : '?' + query + } + + DevicesService.getOboeDevices = function(target, fields, addDevice) { + return oboe('/api/v1/devices?target=' + target + '&fields=' + fields) + .node('devices[*]', function(device) { + addDevice(device) + }) + } + + DevicesService.getDevices = function(target, fields) { + return $http.get('/api/v1/devices?target=' + target + '&fields=' + fields) + } + + DevicesService.getDevice = function(serial, fields) { + return $http.get('/api/v1/devices/' + serial + '?fields=' + fields) + } + + DevicesService.removeDevice = function(serial, filters) { + return $http.delete('/api/v1/devices/' + serial + buildQueryParameters(filters)) + } + + DevicesService.removeDevices = function(filters, serials) { + return $http({ + method: 'DELETE', + url: '/api/v1/devices' + buildQueryParameters(filters), + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + data: typeof serials === 'undefined' ? serials : JSON.stringify({serials: serials}) + }) + } + + DevicesService.addOriginGroupDevice = function(id, serial) { + return $http.put('/api/v1/devices/' + serial + '/groups/' + id) + } + + DevicesService.addOriginGroupDevices = function(id, serials) { + return $http({ + method: 'PUT', + url: '/api/v1/devices/groups/' + id + '?fields=""', + data: typeof serials === 'undefined' ? serials : JSON.stringify({serials: serials}) + }) + } + + DevicesService.removeOriginGroupDevice = function(id, serial) { + return $http.delete('/api/v1/devices/' + serial + '/groups/' + id) + } + + DevicesService.removeOriginGroupDevices = function(id, serials) { + return $http({ + method: 'DELETE', + url: '/api/v1/devices/groups/' + id + '?fields=""', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + data: typeof serials === 'undefined' ? serials : JSON.stringify({serials: serials}) + }) + } + + socket.on('user.settings.devices.created', function(device) { + $rootScope.$broadcast('user.settings.devices.created', device) + $rootScope.$apply() + }) + + socket.on('user.settings.devices.deleted', function(device) { + $rootScope.$broadcast('user.settings.devices.deleted', device) + $rootScope.$apply() + }) + + socket.on('user.settings.devices.updated', function(device) { + $rootScope.$broadcast('user.settings.devices.updated', device) + $rootScope.$apply() + }) + + return DevicesService +} diff --git a/res/app/components/stf/devices/index.js b/res/app/components/stf/devices/index.js new file mode 100644 index 00000000..bb5c85f2 --- /dev/null +++ b/res/app/components/stf/devices/index.js @@ -0,0 +1,7 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = angular.module('stf.devices', [ +]) +.factory('DevicesService', require('./devices-service')) diff --git a/res/app/components/stf/groups/groups-service.js b/res/app/components/stf/groups/groups-service.js new file mode 100644 index 00000000..ea2d74c4 --- /dev/null +++ b/res/app/components/stf/groups/groups-service.js @@ -0,0 +1,181 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const oboe = require('oboe') + +module.exports = function GroupsServiceFactory( + $rootScope +, $http +, socket +) { + const GroupsService = {} + + GroupsService.getGroupUsers = function(id, fields) { + return $http.get('/api/v1/groups/' + id + '/users?fields=' + fields) + } + + GroupsService.getOboeGroupUsers = function(id, fields, addGroupUser) { + return oboe('/api/v1/groups/' + id + '/users?fields=' + fields) + .node('users[*]', function(user) { + addGroupUser(user) + }) + } + + GroupsService.getGroupDevices = function(id, bookable, fields) { + return $http.get('/api/v1/groups/' + id + '/devices?bookable=' + bookable + '&fields=' + fields) + } + + GroupsService.getOboeGroupDevices = function(id, bookable, fields, addGroupDevice) { + return oboe('/api/v1/groups/' + id + '/devices?bookable=' + bookable + '&fields=' + fields) + .node('devices[*]', function(device) { + addGroupDevice(device) + }) + } + + GroupsService.getGroupDevice = function(id, serial, fields) { + return $http.get('/api/v1/groups/' + id + '/devices/' + serial + '?fields=' + fields) + } + + GroupsService.addGroupDevice = function(id, serial) { + return $http.put('/api/v1/groups/' + id + '/devices/' + serial) + } + + GroupsService.addGroupDevices = function(id, serials) { + return $http({ + method: 'PUT', + url: '/api/v1/groups/' + id + '/devices', + data: typeof serials === 'undefined' ? serials : JSON.stringify({serials: serials}) + }) + } + + GroupsService.removeGroupDevice = function(id, serial) { + return $http.delete('/api/v1/groups/' + id + '/devices/' + serial) + } + + GroupsService.removeGroupDevices = function(id, serials) { + return $http({ + method: 'DELETE', + url: '/api/v1/groups/' + id + '/devices', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + data: typeof serials === 'undefined' ? serials : JSON.stringify({serials: serials}) + }) + } + + GroupsService.addGroupUser = function(id, email) { + return $http.put('/api/v1/groups/' + id + '/users/' + email) + } + + GroupsService.addGroupUsers = function(id, emails) { + return $http({ + method: 'PUT', + url: '/api/v1/groups/' + id + '/users', + data: typeof emails === 'undefined' ? emails : JSON.stringify({emails: emails}) + }) + } + + GroupsService.removeGroupUser = function(id, email) { + return $http.delete('/api/v1/groups/' + id + '/users/' + email) + } + + GroupsService.removeGroupUsers = function(id, emails) { + return $http({ + method: 'DELETE', + url: '/api/v1/groups/' + id + '/users', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + data: typeof emails === 'undefined' ? emails : JSON.stringify({emails: emails}) + }) + } + + GroupsService.getOboeGroups = function(addGroup) { + return oboe('/api/v1/groups') + .node('groups[*]', function(group) { + addGroup(group) + }) + } + + GroupsService.getGroups = function() { + return $http.get('/api/v1/groups') + } + + GroupsService.getOboeMyGroups = function(addGroup) { + return oboe('/api/v1/groups?owner=true') + .node('groups[*]', function(group) { + addGroup(group) + }) + } + + GroupsService.getMyGroups = function() { + return $http.get('/api/v1/groups?owner=true') + } + + GroupsService.getGroup = function(id) { + return $http.get('/api/v1/groups/' + id) + } + + GroupsService.removeGroup = function(id) { + return $http.delete('/api/v1/groups/' + id) + } + + GroupsService.removeGroups = function(ids) { + return $http({ + method: 'DELETE', + url: '/api/v1/groups?_=' + Date.now(), + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + data: typeof ids === 'undefined' ? ids : JSON.stringify({ids: ids}) + }) + } + + GroupsService.createGroup = function() { + return $http({ + method: 'POST', + url: '/api/v1/groups', + data: JSON.stringify({'state': 'pending'}) + }) + } + + GroupsService.updateGroup = function(id, data) { + return $http({ + method: 'PUT', + url: '/api/v1/groups/' + id, + data: JSON.stringify(data) + }) + } + socket.on('user.settings.groups.created', function(group) { + $rootScope.$broadcast('user.settings.groups.created', group) + $rootScope.$apply() + }) + + socket.on('user.settings.groups.deleted', function(group) { + $rootScope.$broadcast('user.settings.groups.deleted', group) + $rootScope.$apply() + }) + + socket.on('user.settings.groups.updated', function(group) { + $rootScope.$broadcast('user.settings.groups.updated', group) + $rootScope.$apply() + }) + + socket.on('user.view.groups.created', function(group) { + $rootScope.$broadcast('user.view.groups.created', group) + $rootScope.$apply() + }) + + socket.on('user.view.groups.deleted', function(group) { + $rootScope.$broadcast('user.view.groups.deleted', group) + $rootScope.$apply() + }) + + socket.on('user.view.groups.updated', function(group) { + $rootScope.$broadcast('user.view.groups.updated', group) + $rootScope.$apply() + }) + + return GroupsService +} diff --git a/res/app/components/stf/groups/index.js b/res/app/components/stf/groups/index.js new file mode 100644 index 00000000..0602fa49 --- /dev/null +++ b/res/app/components/stf/groups/index.js @@ -0,0 +1,7 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = angular.module('stf.groups', [ +]) +.factory('GroupsService', require('./groups-service')) diff --git a/res/app/components/stf/tokens/access-token-service.js b/res/app/components/stf/tokens/access-token-service.js index 214aa8cf..00a4e388 100644 --- a/res/app/components/stf/tokens/access-token-service.js +++ b/res/app/components/stf/tokens/access-token-service.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = function AccessTokenServiceFactory( $rootScope , $http @@ -26,7 +30,7 @@ module.exports = function AccessTokenServiceFactory( $rootScope.$apply() }) - socket.on('user.keys.accessToken.removed', function() { + socket.on('user.keys.accessToken.updated', function() { $rootScope.$broadcast('user.keys.accessTokens.updated') $rootScope.$apply() }) diff --git a/res/app/components/stf/user/user-service.js b/res/app/components/stf/user/user-service.js index 0c9e449f..9bba785c 100644 --- a/res/app/components/stf/user/user-service.js +++ b/res/app/components/stf/user/user-service.js @@ -1,5 +1,10 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = function UserServiceFactory( $rootScope +, $http , socket , AppState , AddAdbKeyModalService @@ -8,6 +13,10 @@ module.exports = function UserServiceFactory( var user = UserService.currentUser = AppState.user + UserService.getUser = function() { + return $http.get('/api/v1/user') + } + UserService.getAdbKeys = function() { return (user.adbKeys || (user.adbKeys = [])) } diff --git a/res/app/components/stf/users/index.js b/res/app/components/stf/users/index.js new file mode 100644 index 00000000..b6c36169 --- /dev/null +++ b/res/app/components/stf/users/index.js @@ -0,0 +1,7 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = angular.module('stf.users', [ +]) +.factory('UsersService', require('./users-service')) diff --git a/res/app/components/stf/users/users-service.js b/res/app/components/stf/users/users-service.js new file mode 100644 index 00000000..390c0540 --- /dev/null +++ b/res/app/components/stf/users/users-service.js @@ -0,0 +1,95 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const oboe = require('oboe') + +module.exports = function UsersServiceFactory( + $rootScope +, $http +, socket +) { + const UsersService = {} + + function buildQueryParameters(filters) { + let query = '' + + if (filters.groupOwner !== 'Any') { + query += 'groupOwner=' + filters.groupOwner.toLowerCase() + } + return query === '' ? query : '?' + query + } + + UsersService.getOboeUsers = function(fields, addUser) { + return oboe('/api/v1/users?fields=' + fields) + .node('users[*]', function(user) { + addUser(user) + }) + } + + UsersService.getUsers = function(fields) { + return $http.get('/api/v1/users?fields=' + fields) + } + + UsersService.getUser = function(email, fields) { + return $http.get('/api/v1/users/' + email + '?fields=' + fields) + } + + UsersService.removeUser = function(email, filters) { + return $http.delete('/api/v1/users/' + email + buildQueryParameters(filters)) + } + + UsersService.removeUsers = function(filters, emails) { + return $http({ + method: 'DELETE', + url: '/api/v1/users' + buildQueryParameters(filters), + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + data: typeof emails === 'undefined' ? emails : JSON.stringify({emails: emails}) + }) + } + + UsersService.updateUserGroupsQuotas = function(email, number, duration, repetitions) { + return $http.put( + '/api/v1/users/' + email + + '/groupsQuotas?number=' + number + + '&duration=' + duration + + '&repetitions=' + repetitions + ) + } + + UsersService.updateDefaultUserGroupsQuotas = function(number, duration, repetitions) { + return $http.put( + '/api/v1/users/groupsQuotas?number=' + number + + '&duration=' + duration + + '&repetitions=' + repetitions + ) + } + + UsersService.createUser = function(name, email) { + return $http.post('/api/v1/users/' + email + '?name=' + name) + } + + socket.on('user.settings.users.created', function(user) { + $rootScope.$broadcast('user.settings.users.created', user) + $rootScope.$apply() + }) + + socket.on('user.settings.users.deleted', function(user) { + $rootScope.$broadcast('user.settings.users.deleted', user) + $rootScope.$apply() + }) + + socket.on('user.view.users.updated', function(user) { + $rootScope.$broadcast('user.view.users.updated', user) + $rootScope.$apply() + }) + + socket.on('user.settings.users.updated', function(user) { + $rootScope.$broadcast('user.settings.users.updated', user) + $rootScope.$apply() + }) + + return UsersService +} diff --git a/res/app/components/stf/util/common/common-service.js b/res/app/components/stf/util/common/common-service.js new file mode 100644 index 00000000..de323ac1 --- /dev/null +++ b/res/app/components/stf/util/common/common-service.js @@ -0,0 +1,214 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const _ = require('lodash') + +module.exports = function CommonServiceFactory( + $window, + GenericModalService +) { + const service = {} + + const FIVE_MN = 300 * 1000 + const ONE_HOUR = 3600 * 1000 + const ONE_DAY = 24 * ONE_HOUR + const ONE_WEEK = 7 * ONE_DAY + const ONE_MONTH = 30 * ONE_DAY + const ONE_QUATER = 3 * ONE_MONTH + const ONE_HALF_YEAR = 6 * ONE_MONTH + const ONE_YEAR = 365 * ONE_DAY + + function getClassOptionsField(id, field) { + for(let i in service.classOptions) { + if (service.classOptions[i].id === id) { + return service.classOptions[i][field] + } + } + return '' + } + + service.classOptions = [ + {name: 'Once', id: 'once', privilege: 'user', duration: Infinity}, + {name: 'Hourly', id: 'hourly', privilege: 'user', duration: ONE_HOUR}, + {name: 'Daily', id: 'daily', privilege: 'user', duration: ONE_DAY}, + {name: 'Weekly', id: 'weekly', privilege: 'user', duration: ONE_WEEK}, + {name: 'Monthly', id: 'monthly', privilege: 'user', duration: ONE_MONTH}, + {name: 'Quaterly', id: 'quaterly', privilege: 'user', duration: ONE_QUATER}, + {name: 'Halfyearly', id: 'halfyearly', privilege: 'user', duration: ONE_HALF_YEAR}, + {name: 'Yearly', id: 'yearly', privilege: 'user', duration: ONE_YEAR}, + {name: 'Debug', id: 'debug', privilege: 'admin', duration: FIVE_MN}, + {name: 'Bookable', id: 'bookable', privilege: 'admin', duration: Infinity}, + {name: 'Standard', id: 'standard', privilege: 'admin', duration: Infinity} + ] + + service.getClassName = function(id) { + return getClassOptionsField(id, 'name') + } + + service.getClassDuration = function(id) { + return getClassOptionsField(id, 'duration') + } + + service.getDuration = function(ms) { + if (ms < 1000) { + return '0s' + } + let s = Math.floor(ms / 1000) + let m = Math.floor(s / 60) + + s %= 60 + let h = Math.floor(m / 60) + + m %= 60 + let d = Math.floor(h / 24) + + h %= 24 + return (d === 0 ? '' : d + 'd') + + (h === 0 ? '' : (d === 0 ? '' : ' ') + h + 'h') + + (m === 0 ? '' : (h === 0 ? '' : ' ') + m + 'm') + + (s === 0 ? '' : (m === 0 ? '' : ' ') + s + 's') + } + + service.errorWrapper = function(fn, args) { + return fn.apply(null, args).catch(function(error) { + return GenericModalService.open({ + message: error.data ? + error.data.description : + error.status + ' ' + error.statusText + , type: 'Error' + , size: 'lg' + , cancel: false + }) + .then(function() { + return error + }) + }) + } + + service.getIndex = function(array, value, property) { + for(let i in array) { + if (array[i][property] === value) { + return i + } + } + return -1 + } + + service.merge = function(oldObject, newObject) { + let undefinedValue + + return _.merge(oldObject, newObject, function(a, b) { + return _.isArray(b) ? b : undefinedValue + }) + } + + service.isAddable = function(object, timeStamp) { + return typeof object === 'undefined' || + timeStamp >= object.timeStamp && object.index === -1 + } + + service.isExisting = function(object) { + return typeof object !== 'undefined' && + object.index !== -1 + } + + service.isRemovable = function(object, timeStamp) { + return service.isExisting(object) && + timeStamp >= object.timeStamp + } + + service.add = function(array, objects, value, property, timeStamp) { + if (service.isAddable(objects[value[property]], timeStamp)) { + objects[value[property]] = { + index: array.push(value) - 1 + , timeStamp: timeStamp + } + return array[objects[value[property]].index] + } + return null + } + + service.update = function(array, objects, value, property, timeStamp, noAdding) { + if (service.isExisting(objects[value[property]])) { + service.merge(array[objects[value[property]].index], value) + objects[value[property]].timeStamp = timeStamp + return array[objects[value[property]].index] + } + else if (!noAdding) { + return service.add(array, objects, value, property, timeStamp) + } + return null + } + + service.delete = function(array, objects, key, timeStamp) { + if (service.isRemovable(objects[key], timeStamp)) { + const index = objects[key].index + const value = array.splice(index, 1)[0] + + objects[key].index = -1 + objects[key].timeStamp = timeStamp + for (let key in objects) { + if (objects[key].index > index) { + objects[key].index-- + } + } + return value + } + else if (typeof objects[key] === 'undefined') { + objects[key] = { + index: -1 + , timeStamp: timeStamp + } + } + return null + } + + service.sortBy = function(data, column) { + const index = service.getIndex(data.columns, column.name, 'name') + + if (index !== data.sort.index) { + data.sort.reverse = false + column.sort = 'sort-asc' + data.columns[data.sort.index].sort = 'none' + data.sort.index = index + } + else { + data.sort.reverse = !data.sort.reverse + column.sort = column.sort === 'sort-asc' ? 'sort-desc' : 'sort-asc' + } + return service + } + + service.isOriginGroup = function(_class) { + return _class === 'bookable' || _class === 'standard' + } + + service.isNoRepetitionsGroup = function(_class) { + return service.isOriginGroup(_class) || _class === 'once' + } + + service.url = function(url) { + const a = $window.document.createElement('a') + + $window.document.body.appendChild(a) + a.href = url + a.click() + $window.document.body.removeChild(a) + return service + } + + service.copyToClipboard = function(data) { + const input = $window.document.createElement('input') + + $window.document.body.appendChild(input) + input.value = data + input.select() + $window.document.execCommand('copy') + $window.document.body.removeChild(input) + return service + } + + return service +} + diff --git a/res/app/components/stf/util/common/index.js b/res/app/components/stf/util/common/index.js new file mode 100644 index 00000000..394bc761 --- /dev/null +++ b/res/app/components/stf/util/common/index.js @@ -0,0 +1,8 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = angular.module('stf.util.common', [ + require('stf/common-ui').name +]) +.factory('CommonService', require('./common-service')) diff --git a/res/app/control-panes/control-panes-controller.js b/res/app/control-panes/control-panes-controller.js index 5a5db164..1fccfdf7 100644 --- a/res/app/control-panes/control-panes-controller.js +++ b/res/app/control-panes/control-panes-controller.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = function ControlPanesController($scope, $http, gettext, $routeParams, $timeout, $location, DeviceService, GroupService, ControlService, @@ -85,7 +89,9 @@ module.exports = $scope.$watch('device.state', function(newValue, oldValue) { if (newValue !== oldValue) { - if (oldValue === 'using') { +/*************** fix bug: it seems automation state was forgotten ? *************/ + if (oldValue === 'using' || oldValue === 'automation') { +/******************************************************************************/ FatalMessageService.open($scope.device, false) } } diff --git a/res/app/device-list/column/device-column-service.js b/res/app/device-list/column/device-column-service.js index 96dafd30..754a9616 100644 --- a/res/app/device-list/column/device-column-service.js +++ b/res/app/device-list/column/device-column-service.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var _ = require('lodash') var filterOps = { @@ -18,7 +22,7 @@ var filterOps = { } } -module.exports = function DeviceColumnService($filter, gettext) { +module.exports = function DeviceColumnService($filter, gettext, SettingsService, AppState) { // Definitions for all possible values. return { state: DeviceStatusCell({ @@ -27,6 +31,52 @@ module.exports = function DeviceColumnService($filter, gettext) { return $filter('translate')(device.enhancedStateAction) } }) + , group: TextCell({ + title: gettext('Group Name') + , value: function(device) { + return $filter('translate')(device.group.name) + } + }) + , groupSchedule: TextCell({ + title: gettext('Group Class') + , value: function(device) { + return $filter('translate')(device.group.class) + } + }) + , groupOwner: LinkCell({ + title: gettext('Group Owner') + , target: '_blank' + , value: function(device) { + return $filter('translate')(device.group.owner.name) + } + , link: function(device) { + return device.enhancedGroupOwnerProfileUrl + } + }) + , groupEndTime: TextCell({ + title: gettext('Group Expiration Date') + , value: function(device) { + return $filter('date')(device.group.lifeTime.stop, SettingsService.get('dateFormat')) + } + }) + , groupStartTime: TextCell({ + title: gettext('Group Starting Date') + , value: function(device) { + return $filter('date')(device.group.lifeTime.start, SettingsService.get('dateFormat')) + } + }) + , groupRepetitions: TextCell({ + title: gettext('Group Repetitions') + , value: function(device) { + return device.group.repetitions + } + }) + , groupOrigin: TextCell({ + title: gettext('Group Origin') + , value: function(device) { + return $filter('translate')(device.group.originName) + } + }) , model: DeviceModelCell({ title: gettext('Model') , value: function(device) { @@ -38,7 +88,7 @@ module.exports = function DeviceColumnService($filter, gettext) { , value: function(device) { return device.name || device.model || device.serial } - }) + }, AppState.user.email) , operator: TextCell({ title: gettext('Carrier') , value: function(device) { @@ -305,8 +355,10 @@ function zeroPadTwoDigit(digit) { } function compareIgnoreCase(a, b) { - var la = (a || '').toLowerCase() - var lb = (b || '').toLowerCase() +/***** fix bug: cast to String for Safari compatibility ****/ + var la = (String(a) || '').toLowerCase() + var lb = (String(b) || '').toLowerCase() +/***********************************************************/ if (la === lb) { return 0 } @@ -316,8 +368,10 @@ function compareIgnoreCase(a, b) { } function filterIgnoreCase(a, filterValue) { - var va = (a || '').toLowerCase() - var vb = filterValue.toLowerCase() +/***** fix bug: cast to String for Safari compatibility ****/ + var va = (String(a) || '').toLowerCase() + var vb = String(filterValue).toLowerCase() +/***********************************************************/ return va.indexOf(vb) !== -1 } @@ -551,7 +605,7 @@ function DeviceModelCell(options) { }) } -function DeviceNameCell(options) { +function DeviceNameCell(options, ownerEmail) { return _.defaults(options, { title: options.title , defaultOrder: 'asc' @@ -566,11 +620,11 @@ function DeviceNameCell(options) { var a = td.firstChild var t = a.firstChild - if (device.using) { + if (device.using && device.owner.email === ownerEmail) { a.className = 'device-product-name-using' a.href = '#!/control/' + device.serial } - else if (device.usable) { + else if (device.usable && !device.using) { a.className = 'device-product-name-usable' a.href = '#!/control/' + device.serial } diff --git a/res/app/device-list/column/index.js b/res/app/device-list/column/index.js index 8f4323a5..e1aa0fdf 100644 --- a/res/app/device-list/column/index.js +++ b/res/app/device-list/column/index.js @@ -1,4 +1,10 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = angular.module('stf.device-list.column', [ - require('gettext').name + require('gettext').name, + require('stf/settings').name, + require('stf/app-state').name ]) .service('DeviceColumnService', require('./device-column-service')) diff --git a/res/app/device-list/device-list-controller.js b/res/app/device-list/device-list-controller.js index f14109e4..ec97c897 100644 --- a/res/app/device-list/device-list-controller.js +++ b/res/app/device-list/device-list-controller.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + var QueryParser = require('./util/query-parser') module.exports = function DeviceListCtrl( @@ -123,6 +127,34 @@ module.exports = function DeviceListCtrl( name: 'owner' , selected: true } + , { + name: 'group' + , selected: false + } + , { + name: 'groupSchedule' + , selected: false + } + , { + name: 'groupStartTime' + , selected: false + } + , { + name: 'groupEndTime' + , selected: false + } + , { + name: 'groupRepetitions' + , selected: false + } + , { + name: 'groupOwner' + , selected: false + } + , { + name: 'groupOrigin' + , selected: false + } ] $scope.columns = defaultColumns diff --git a/res/app/device-list/stats/device-list-stats-directive.js b/res/app/device-list/stats/device-list-stats-directive.js index bd8c6f6c..ac47dda8 100644 --- a/res/app/device-list/stats/device-list-stats-directive.js +++ b/res/app/device-list/stats/device-list-stats-directive.js @@ -1,3 +1,7 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + module.exports = function DeviceListStatsDirective( UserService ) { @@ -73,8 +77,11 @@ module.exports = function DeviceListStatsDirective( var newStats = updateStats(device) scope.counter.total -= 1 - scope.counter.busy += newStats.busy - oldStats.busy - scope.counter.using += newStats.using - oldStats.using + scope.counter.usable -= newStats.usable + scope.counter.busy -= newStats.busy + scope.counter.using -= newStats.using + //scope.counter.busy += newStats.busy - oldStats.busy + //scope.counter.using += newStats.using - oldStats.using delete mapping[device.serial] diff --git a/res/app/group-list/group-list-controller.js b/res/app/group-list/group-list-controller.js new file mode 100644 index 00000000..afaf4e14 --- /dev/null +++ b/res/app/group-list/group-list-controller.js @@ -0,0 +1,463 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const _ = require('lodash') + +module.exports = function GroupListCtrl( + $scope +, $filter +, GroupsService +, UserService +, UsersService +, DevicesService +, SettingsService +, ItemsPerPageOptionsService +, CommonService +) { + const users = [] + const usersByEmail = {} + const devices = [] + const devicesBySerial = {} + const groupsById = {} + const groupsEnv = {} + const groupUserToAdd = {} + const userFields = + 'email,' + + 'name,' + + 'privilege' + const deviceFields = + 'serial,' + + 'version,' + + 'manufacturer,' + + 'sdk,' + + 'display.width,' + + 'display.height,' + + 'model' + + function incrStateStats(group, incr) { + if (group.isActive) { + $scope.activeGroups += incr + } + else if (group.state === 'pending') { + $scope.pendingGroups += incr + } + $scope.readyGroups = $scope.groups.length - $scope.activeGroups - $scope.pendingGroups + } + + function updateStateStats(oldGroup, newGroup) { + if (oldGroup === null) { + incrStateStats(newGroup, 1) + } + else if (newGroup === null) { + incrStateStats(oldGroup, -1) + } + else { + if (newGroup.isActive && !oldGroup.isActive) { + incrStateStats(newGroup, 1) + } + else if (!newGroup.isActive && oldGroup.isActive) { + incrStateStats(oldGroup, -1) + } + else if (newGroup.state === 'ready' && oldGroup.state === 'pending') { + incrStateStats(oldGroup, -1) + } + } + } + + function updateGroupExtraProperties(group) { + const status = {pending: 'Pending', waiting: 'Waiting', ready: 'Ready'} + + group.status = group.isActive ? 'Active' : status[group.state] + group.startTime = $filter('date')(group.dates[0].start, SettingsService.get('dateFormat')) + group.stopTime = $filter('date')(group.dates[0].stop, SettingsService.get('dateFormat')) + + } + + function updateQuotaBar(bar, consumed, allocated) { + bar.value = (consumed / allocated) * 100 | 0 + if (bar.value < 25) { + bar.type = 'success' + } + else if (bar.value < 50) { + bar.type = 'info' + } + else if (bar.value < 75) { + bar.type = 'warning' + } + else { + bar.type = 'danger' + } + } + + function updateQuotaBars() { + updateQuotaBar( + $scope.numberBar + , $scope.user.groups.quotas.consumed.number + , $scope.user.groups.quotas.allocated.number + ) + updateQuotaBar( + $scope.durationBar + , $scope.user.groups.quotas.consumed.duration + , $scope.user.groups.quotas.allocated.duration + ) + } + + function addGroup(group, timeStamp) { + if (CommonService.add( + $scope.groups + , groupsById + , group + , 'id' + , timeStamp)) { + $scope.groupsEnv[group.id] = { + devices: [] + , users: [] + } + groupsEnv[group.id] = { + devicesBySerial: {} + , usersByEmail: {} + } + updateStateStats(null, group) + updateGroupExtraProperties(group) + return group + } + return null + } + + function updateGroup(group, timeStamp) { + return CommonService.update( + $scope.groups + , groupsById + , group + , 'id' + , timeStamp) + } + + function deleteGroup(id, timeStamp) { + const group = CommonService.delete( + $scope.groups + , groupsById + , id + , timeStamp) + + if (group) { + updateStateStats(group, null) + delete $scope.groupsEnv[group.id] + delete groupsEnv[group.id] + } + return group + } + + function addUser(user, timeStamp) { + if (CommonService.add( + users + , usersByEmail + , user + , 'email' + , timeStamp + ) && typeof groupUserToAdd[user.email] !== 'undefined') { + addGroupUser( + groupUserToAdd[user.email].id + , user.email + , groupUserToAdd[user.email].timeStamp) + delete groupUserToAdd[user.email] + } + } + + function deleteUser(email, timeStamp) { + return CommonService.delete( + users + , usersByEmail + , email + , timeStamp) + } + + function addDevice(device, timeStamp) { + return CommonService.add( + devices + , devicesBySerial + , device + , 'serial' + , timeStamp) + } + + function updateDevice(device, timeStamp) { + return CommonService.update( + devices + , devicesBySerial + , device + , 'serial' + , timeStamp) + } + + function deleteDevice(serial, timeStamp) { + return CommonService.delete( + devices + , devicesBySerial + , serial + , timeStamp) + } + + function addGroupUser(id, email, timeStamp) { + if (CommonService.isExisting(usersByEmail[email])) { + CommonService.add( + $scope.groupsEnv[id].users + , groupsEnv[id].usersByEmail + , users[usersByEmail[email].index] + , 'email' + , timeStamp) + } + else { + groupUserToAdd[email] = {id: id, timeStamp: timeStamp} + } + } + + function deleteGroupUser(id, email, timeStamp) { + CommonService.delete( + $scope.groupsEnv[id].users + , groupsEnv[id].usersByEmail + , email + , timeStamp) + } + + function addGroupDevice(id, serial, timeStamp) { + if (CommonService.isExisting(devicesBySerial[serial])) { + CommonService.add( + $scope.groupsEnv[id].devices + , groupsEnv[id].devicesBySerial + , devices[devicesBySerial[serial].index] + , 'serial' + , timeStamp) + } + else { + GroupsService.getGroupDevice(id, serial, deviceFields) + .then(function(response) { + if (addDevice(response.data.device, timeStamp)) { + CommonService.add( + $scope.groupsEnv[id].devices + , groupsEnv[id].devicesBySerial + , devices[devicesBySerial[serial].index] + , 'serial' + , timeStamp) + } + }) + } + } + + function deleteGroupDevice(id, serial, timeStamp) { + CommonService.delete( + $scope.groupsEnv[id].devices + , groupsEnv[id].devicesBySerial + , serial + , timeStamp) + } + + function updateGroupDevices(group, isAddedDevice, devices, timeStamp) { + if (devices.length) { + if (isAddedDevice) { + devices.forEach(function(serial) { + addGroupDevice(group.id, serial, timeStamp) + }) + } + else { + devices.forEach(function(serial) { + deleteGroupDevice(group.id, serial, timeStamp) + }) + } + } + } + + function updateGroupUsers(group, isAddedUser, users, timeStamp) { + if (users.length) { + if (isAddedUser) { + users.forEach(function(email) { + addGroupUser(group.id, email, timeStamp) + }) + } + else { + users.forEach(function(email) { + deleteGroupUser(group.id, email, timeStamp) + }) + } + } + } + + function initScope() { + GroupsService.getOboeGroups(function(group) { + addGroup(group, -1) + }) + .done(function() { + $scope.$digest() + }) + + UserService.getUser().then(function(response) { + $scope.user = response.data.user + updateQuotaBars() + }) + + UsersService.getOboeUsers(userFields, function(user) { + addUser(user, -1) + }) + } + + $scope.scopeGroupListCtrl = $scope + $scope.sortBy = CommonService.sortBy + $scope.getDuration = CommonService.getDuration + $scope.getClassName = CommonService.getClassName + $scope.user = UserService.currentUser + $scope.numberBar = {} + $scope.durationBar = {} + $scope.groupsEnv = {} + $scope.groups = [] + $scope.activeGroups = $scope.readyGroups = $scope.pendingGroups = 0 + $scope.itemsPerPageOptions = ItemsPerPageOptionsService + SettingsService.bind($scope, { + target: 'groupItemsPerPage' + , source: 'groupViewItemsPerPage' + , defaultValue: $scope.itemsPerPageOptions[2] + }) + $scope.groupColumns = [ + {name: 'Status', property: 'status'} + , {name: 'Name', property: 'name'} + , {name: 'Identifier', property: 'id'} + , {name: 'Owner', property: 'owner.name'} + , {name: 'Devices', property: 'devices.length'} + , {name: 'Users', property: 'users.length'} + , {name: 'Class', property: 'class'} + , {name: 'Repetitions', property: 'repetitions'} + , {name: 'Duration', property: 'duration'} + , {name: 'Starting Date', property: 'startTime'} + , {name: 'Expiration Date', property: 'stopTime'} + ] + $scope.defaultGroupData = { + columns: [ + {name: 'Status', selected: true, sort: 'none'} + , {name: 'Name', selected: true, sort: 'sort-asc'} + , {name: 'Identifier', selected: false, sort: 'none'} + , {name: 'Owner', selected: true, sort: 'none'} + , {name: 'Devices', selected: true, sort: 'none'} + , {name: 'Users', selected: true, sort: 'none'} + , {name: 'Class', selected: true, sort: 'none'} + , {name: 'Repetitions', selected: true, sort: 'none'} + , {name: 'Duration', selected: true, sort: 'none'} + , {name: 'Starting Date', selected: true, sort: 'none'} + , {name: 'Expiration Date', selected: true, sort: 'none'} + ] + , sort: {index: 1, reverse: false} + } + SettingsService.bind($scope, { + target: 'groupData' + , source: 'groupData' + , defaultValue: $scope.defaultGroupData + }) + + $scope.mailToGroupOwners = function(groups) { + CommonService.copyToClipboard(_.uniq(groups.map(function(group) { + return group.owner.email + })) + .join(SettingsService.get('emailSeparator'))) + .url('mailto:?body=*** Paste the email addresses from the clipboard! ***') + } + + $scope.mailToGroupUsers = function(group, users) { + // group unused actually.. + CommonService.copyToClipboard(users.map(function(user) { + return user.email + }) + .join(SettingsService.get('emailSeparator'))) + .url('mailto:?body=*** Paste the email addresses from the clipboard! ***') + } + + $scope.getTooltip = function(objects) { + let tooltip = '' + + objects.forEach(function(object) { + tooltip += object + '\n' + }) + return tooltip + } + + $scope.resetData = function() { + $scope.groupData = JSON.parse(JSON.stringify($scope.defaultGroupData)) + } + + $scope.initGroupUsers = function(group) { + if (typeof $scope.groupsEnv[group.id].userCurrentPage === 'undefined') { + $scope.groupsEnv[group.id].userCurrentPage = 1 + $scope.groupsEnv[group.id].userItemsPerPage = $scope.itemsPerPageOptions[1] + } + group.users.forEach(function(email) { + addGroupUser(group.id, email, -1) + }) + } + + $scope.initGroupDevices = function(group) { + if (typeof $scope.groupsEnv[group.id].deviceCurrentPage === 'undefined') { + $scope.groupsEnv[group.id].deviceCurrentPage = 1 + $scope.groupsEnv[group.id].deviceItemsPerPage = $scope.itemsPerPageOptions[1] + } + GroupsService.getOboeGroupDevices(group.id, false, deviceFields, function(device) { + addDevice(device, -1) + addGroupDevice(group.id, device.serial, -1) + }) + .done(function() { + $scope.$digest() + }) + } + + $scope.$on('user.view.groups.created', function(event, message) { + addGroup(message.group, message.timeStamp) + }) + + $scope.$on('user.view.groups.deleted', function(event, message) { + deleteGroup(message.group.id, message.timeStamp) + }) + + $scope.$on('user.view.groups.updated', function(event, message) { + if (CommonService.isExisting(groupsById[message.group.id])) { + if (message.group.users.indexOf(UserService.currentUser.email) < 0) { + deleteGroup(message.group.id, message.timeStamp) + } + else { + updateStateStats($scope.groups[groupsById[message.group.id].index], message.group) + updateGroupDevices(message.group, message.isAddedDevice, message.devices, message.timeStamp) + updateGroupUsers(message.group, message.isAddedUser, message.users, message.timeStamp) + updateGroup(message.group, message.timeStamp) + updateGroupExtraProperties($scope.groups[groupsById[message.group.id].index]) + } + } + else { + addGroup(message.group, message.timeStamp) + } + }) + + $scope.$on('user.settings.users.created', function(event, message) { + addUser(message.user, message.timeStamp) + }) + + $scope.$on('user.settings.users.deleted', function(event, message) { + deleteUser(message.user.email, message.timeStamp) + }) + + $scope.$on('user.view.users.updated', function(event, message) { + if (message.user.email === $scope.user.email) { + $scope.user = message.user + updateQuotaBars() + } + }) + + $scope.$on('user.settings.devices.created', function(event, message) { + addDevice(message.device, message.timeStamp) + }) + + $scope.$on('user.settings.devices.deleted', function(event, message) { + deleteDevice(message.device.serial, message.timeStamp) + }) + + $scope.$on('user.settings.devices.updated', function(event, message) { + updateDevice(message.device, message.timeStamp) + }) + + initScope() +} diff --git a/res/app/group-list/group-list.css b/res/app/group-list/group-list.css new file mode 100644 index 00000000..f6a9c4d6 --- /dev/null +++ b/res/app/group-list/group-list.css @@ -0,0 +1,196 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +.stf-pager-group-devices-search { + width: 160px; +} + +.stf-pager-group-list-total-items { + margin-top: 5px; +} + +.stf-group-list .selectable { + user-select: text; +} + +.stf-group-list .group-list .stf-pager-group-devices-search i.stf-pager-group-devices-search-icon { + font-size: 12px; + margin-right: 0px; +} + +.stf-group-list .group-list { + min-height: 600px; +} + +.stf-group-list .group-list-header { + margin: 20px 0px 20px 15px; +} + +.stf-group-list .btn-devices, .btn-users { + padding: 0px; + margin: 0px; +} + +.stf-group-list .group-devices { + width: auto; + min-width: 475px; + max-width: 600px; +} + +.stf-group-list .group-users { + width: auto; + min-width: 475px; + max-width: 600px; +} + +.stf-group-list .group-icon { + font-size: 15px; + vertical-align: middle; + margin-right: 10px; +} + +.stf-group-list .group-device-icon { + font-size: 25px; + vertical-align: middle; + margin-right: 10px; +} + +.stf-group-list .group-user-icon { + font-size: 20px; + vertical-align: middle; + margin-right: 10px; +} + +.stf-group-list .group-device-details, .group-user-details { + display: inline-block; + line-height: 2; + margin-left: 10px; +} + +.stf-group-list .group-device-name, .group-user-name { + color: #007aff; + font-size: 14px; + font-weight: 300; + margin: 2px 0 6px; +} + +.stf-group-list .group-device-id, .group-user-id { + font-family: Monaco, Menlo, Consolas, "Courier New", monospace; + font-size: 10px; + margin-bottom: 2px; + color: #999999; + font-weight: 300; +} + +.stf-group-list td,span { + white-space: nowrap; +} + +.stf-group-list .user-line, .device-line { + padding: 9px 15px 9px 2px; + margin-left: 14px; + border-bottom: 1px solid #dddddd; +} + +.stf-group-list .mailto { + padding-bottom: 1px; + margin-top: 5px; +} + +.stf-group-list i.mailto { + vertical-align: initial; +} + +.group-stats { + min-height: 100px; + height: 100px; + text-align: center; +} + + +.group-stats [class^="col-"], +.group-stats [class*="col-"] { + height: 100%; + margin-bottom: 0; +} + +.group-stats [class^="col-"]:last-child, +.group-stats [class*="col-"]:last-child { + border: 0; +} + +.group-stats [class^="col-"] .number, +.group-stats [class*="col-"] .number { + font-size: 3.4em; + font-weight: 100; + line-height: 1.5em; + letter-spacing: -0.06em; +} + +.group-stats [class^="col-"] .number .icon, +.group-stats [class*="col-"] .number .icon { + width: 50px; + height: 38px; + display: inline-block; + vertical-align: top; + margin: 20px 12px 0 0; +} + +.group-quota-stats { + min-height: 75px; + height: 75px; + text-align: center; +} + +.group-quota-stats .bar { + height: 20px; + vertical-align: top; + margin: 14px 12px 12px 12px; +} + +.group-quota-stats .text, +.group-stats [class^="col-"] .text, +.group-stats [class*="col-"] .text { + font-weight: 300; + color: #aeaeae; + text-transform: uppercase; + font-size: 12px; +} + +.group-stats .fa { + font-size: 0.8em; +} + + +@media (max-width: 600px) { + .group-stats { + min-height: 60px; + height: 60px; + text-align: center; + } + + .group-stats .fa { + font-size: 0.6em; + } + + .group-stats [class^="col-"] .number, + .group-stats [class*="col-"] .number { + font-size: 1.8em; + line-height: normal; + font-weight: 300; + } + + .group-stats [class^="col-"] .number .icon, + .group-stats [class*="col-"] .number .icon { + width: 25px; + height: 19px; + margin: 10px 6px 0 0; + } + + .group-stats [class^="col-"] .text, + .group-stats [class*="col-"] .text { + font-size: 0.8em; + font-weight: 500; + } +} diff --git a/res/app/group-list/group-list.pug b/res/app/group-list/group-list.pug new file mode 100644 index 00000000..7a109cee --- /dev/null +++ b/res/app/group-list/group-list.pug @@ -0,0 +1,14 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.stf-group-list + .row.unselectable + .col-md-12 + div(ng-include="'group-list/stats/group-stats.pug'") + .row.unselectable + .col-md-12 + div(ng-include="'group-list/stats/group-quota-stats.pug'") + .row.unselectable + .col-md-12 + div(ng-include="'group-list/groups/groups.pug'") diff --git a/res/app/group-list/groups/groups.pug b/res/app/group-list/groups/groups.pug new file mode 100644 index 00000000..f985b25a --- /dev/null +++ b/res/app/group-list/groups/groups.pug @@ -0,0 +1,167 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.fluid-height.overflow-auto.group-list + .heading + nothing-to-show( + icon='fa-object-group' + message='{{"No Groups" | translate}}' ng-if='!groups.length') + + div(ng-if='groups.length') + form.form-inline + .form-group.group-list-header + stf-pager( + tooltip-label="{{'Group selection' | translate}}" + total-items='filteredGroups.length' + total-items-style='stf-pager-group-list-total-items' + items-per-page='scopeGroupListCtrl.groupItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='scopeGroupListCtrl.groupCurrentPage' + items-search='search') + + .form-group.group-list-header + stf-column-choice(reset-data='resetData()' column-data='groupData.columns') + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + uib-tooltip="{{'Write an email to the group owner selection' | translate}}" + ng-disabled='!filteredGroups.length' + ng-click='mailToGroupOwners(filteredGroups)' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Owners + + table.table.table-hover.dataTable.ng-table + thead + tr + th.header.sortable( + ng-class='[column.sort]' + ng-repeat='column in groupData.columns | filter: {selected: true}' + ng-click='sortBy(groupData, column)') + div.strong(ng-bind-template='{{::column.name | translate}}') + tbody + tr(ng-repeat="group in groups \ + | filter:search \ + | orderBy:groupColumns[groupData.sort.index].property:groupData.sort.reverse \ + | pagedObjectsFilter:scopeGroupListCtrl:'groupCurrentPage':'groupItemsPerPage':'filteredGroups' \ + track by group.id") + + td(ng-if='groupData.columns[0].selected' + ng-class="{'color-green': group.status === 'Active', \ + 'color-red': group.status === 'Pending', \ + 'color-orange': group.status === 'Ready'}") {{group.status | translate}} + td.selectable(ng-if='groupData.columns[1].selected') + i.fa.fa-object-group.group-icon + span {{group.name}} + td.selectable(ng-if='groupData.columns[2].selected') {{::group.id}} + td(ng-if='groupData.columns[3].selected') + a(ng-href="{{::'mailto:' + group.owner.email}}") {{::group.owner.name}} + + td(ng-if='groupData.columns[4].selected') + .btn-group.btn-devices(uib-dropdown auto-close='outsideClick') + button.btn.btn-sm.btn-primary-outline.btn-devices( + type='button' + ng-disabled='!group.devices.length' + ng-click='initGroupDevices(group)' + uib-dropdown-toggle) + span {{group.devices.length}} + + ul.dropdown-menu.group-devices( + ng-if='groupsEnv[group.id].deviceCurrentPage && groupsEnv[group.id].devices.length' + uib-dropdown-menu role='menu' ng-click='$event.stopPropagation()') + li + a + form.form-inline + .form-group + stf-pager( + items-search-style='stf-pager-group-devices-search' + icon-style='stf-pager-group-devices-search-icon' + tooltip-label="{{'Device selection' | translate}}" + total-items='groupsEnv[group.id].filteredDevices.length' + items-per-page='groupsEnv[group.id].deviceItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='groupsEnv[group.id].deviceCurrentPage' + items-search='deviceSearch') + + li(ng-repeat="device in groupsEnv[group.id].devices \ + | filter:deviceSearch \ + | orderBy: 'model' \ + | pagedObjectsFilter:groupsEnv[group.id]:'deviceCurrentPage':'deviceItemsPerPage':'filteredDevices' \ + track by device.serial") + + .device-line + i.fa.fa-mobile.group-device-icon + .group-device-details.selectable + a.group-device-name(ng-bind-template="{{device.manufacturer + ' ' + device.model}}") + .group-device-id + span(translate) Serial + span(ng-bind-template="{{::': ' + device.serial + ' - '}}") + span(translate) OS + span(ng-bind-template="{{': ' + device.version + ' - '}}") + span(translate) Screen + span(ng-bind-template="{{': ' + device.display.width + 'x' + device.display.height + ' - '}}") + span(translate) SDK + span(ng-bind-template="{{': ' + device.sdk}}") + + td(ng-if='groupData.columns[5].selected') + .btn-group.btn-users(uib-dropdown auto-close='outsideClick') + button.btn.btn-sm.btn-primary-outline.btn-users( + type='button' + ng-disabled='!group.users.length' + ng-click='initGroupUsers(group)' + uib-dropdown-toggle) + span {{group.users.length}} + + ul.dropdown-menu.group-users( + ng-if='groupsEnv[group.id].userCurrentPage && groupsEnv[group.id].users' + uib-dropdown-menu role='menu' ng-click='$event.stopPropagation()') + li + .user-line + form + .form-group.mailto + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + uib-tooltip="{{'Write an email to the group user selection' | translate}}" + ng-disabled='!groupsEnv[group.id].filteredUsers.length' + ng-click='mailToGroupUsers(group, groupsEnv[group.id].filteredUsers)' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Users + + form.form-inline + .form-group + stf-pager( + items-search-style='stf-pager-group-devices-search' + icon-style='stf-pager-group-devices-search-icon' + tooltip-label="{{'User selection' | translate}}" + total-items='groupsEnv[group.id].filteredUsers.length' + items-per-page='groupsEnv[group.id].userItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='groupsEnv[group.id].userCurrentPage' + items-search='userSearch') + + li(ng-repeat="user in groupsEnv[group.id].users \ + | filter:userSearch \ + | orderBy: 'name' \ + | pagedObjectsFilter:groupsEnv[group.id]:'userCurrentPage':'userItemsPerPage':'filteredUsers' \ + track by user.email") + .user-line + i.fa.fa-user.group-user-icon + .group-user-details.selectable + a.group-user-name( + ng-href="{{::'mailto:' + user.email}}" + ng-bind-template="{{::user.name}}") + .group-user-id + span(translate) Email + span(ng-bind-template="{{::': ' + user.email + ' - '}}") + span(translate) Privilege + span(ng-bind-template="{{::': ' + user.privilege}}") + + td(ng-if='groupData.columns[6].selected') {{getClassName(group.class) | translate}} + td(ng-if='groupData.columns[7].selected') {{group.repetitions}} + td(ng-if='groupData.columns[8].selected') {{getDuration(group.duration)}} + td(ng-if='groupData.columns[9].selected') {{group.startTime}} + td(ng-if='groupData.columns[10].selected') {{group.stopTime}} diff --git a/res/app/group-list/index.js b/res/app/group-list/index.js new file mode 100644 index 00000000..ca9608e8 --- /dev/null +++ b/res/app/group-list/index.js @@ -0,0 +1,35 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +require('./group-list.css') + +module.exports = angular.module('group-list', [ + require('stf/column-choice').name, + require('stf/groups').name, + require('stf/user').name, + require('stf/users').name, + require('stf/devices').name, + require('stf/settings').name, + require('stf/util/common').name, + require('stf/common-ui').name +]) + .config(['$routeProvider', function($routeProvider) { + $routeProvider + .when('/groups', { + template: require('./group-list.pug'), + controller: 'GroupListCtrl' + }) + }]) + .run(['$templateCache', function($templateCache) { + $templateCache.put( + 'group-list/stats/group-stats.pug', require('./stats/group-stats.pug') + ) + $templateCache.put( + 'group-list/stats/group-quota-stats.pug', require('./stats/group-quota-stats.pug') + ) + $templateCache.put( + 'group-list/groups/groups.pug', require('./groups/groups.pug') + ) + }]) + .controller('GroupListCtrl', require('./group-list-controller')) diff --git a/res/app/group-list/stats/group-quota-stats.pug b/res/app/group-list/stats/group-quota-stats.pug new file mode 100644 index 00000000..08ca3973 --- /dev/null +++ b/res/app/group-list/stats/group-quota-stats.pug @@ -0,0 +1,14 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.group-quota-stats + .col-xs-6 + uib-progressbar.bar(class='progress-striped' value='numberBar.value' type='{{numberBar.type}}') + b {{numberBar.value}}% + .text(translate) {{user.name}} groups number use + .col-xs-6 + uib-progressbar.bar(class='progress-striped' value='durationBar.value' type='{{durationBar.type}}') + b {{durationBar.value}}% + .text(translate) {{user.name}} groups duration use + diff --git a/res/app/group-list/stats/group-stats.pug b/res/app/group-list/stats/group-stats.pug new file mode 100644 index 00000000..1638c619 --- /dev/null +++ b/res/app/group-list/stats/group-stats.pug @@ -0,0 +1,25 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.group-stats + .col-xs-3 + .number.color-blue + .icon.fa.fa-globe + span(ng-bind='groups.length') + .text(translate) Total groups + .col-xs-3 + .number.color-green + .icon.fa.fa-play + span(ng-bind='activeGroups') + .text(translate) Active groups + .col-xs-3 + .number.color-orange + .icon.fa.fa-pause + span(ng-bind='readyGroups') + .text(translate) Ready groups + .col-xs-3 + .number.color-pink + .icon.fa.fa-stop + span(ng-bind='pendingGroups') + .text(translate) Pending groups diff --git a/res/app/menu/index.js b/res/app/menu/index.js index 89f72ea5..88ab1128 100644 --- a/res/app/menu/index.js +++ b/res/app/menu/index.js @@ -1,6 +1,14 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + require('./menu.css') +require('angular-cookies') module.exports = angular.module('stf.menu', [ + 'ngCookies', + require('stf/socket').name, + require('stf/util/common').name, require('stf/nav-menu').name, require('stf/settings').name, require('stf/common-ui/modals/external-url-modal').name, diff --git a/res/app/menu/menu-controller.js b/res/app/menu/menu-controller.js index b053b9f8..7084a65f 100644 --- a/res/app/menu/menu-controller.js +++ b/res/app/menu/menu-controller.js @@ -1,5 +1,17 @@ -module.exports = function MenuCtrl($scope, $rootScope, SettingsService, - $location) { +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function MenuCtrl( + $scope +, $rootScope +, SettingsService +, $location +, $http +, CommonService +, socket +, $cookies +, $window) { SettingsService.bind($scope, { target: 'lastUsedDevice' @@ -14,4 +26,21 @@ module.exports = function MenuCtrl($scope, $rootScope, SettingsService, $scope.isControlRoute = $location.path().search('/control') !== -1 }) + $scope.mailToSupport = function() { + CommonService.url('mailto:' + $scope.contactEmail) + } + + $http.get('/auth/contact').then(function(response) { + $scope.contactEmail = response.data.contact.email + }) + + $scope.logout = function() { + $cookies.remove('XSRF-TOKEN', {path: '/'}) + $cookies.remove('ssid', {path: '/'}) + $cookies.remove('ssid.sig', {path: '/'}) + $window.location = '/' + setTimeout(function() { + socket.disconnect() + }, 100) + } } diff --git a/res/app/menu/menu.pug b/res/app/menu/menu.pug index 17b83e8c..49a241a0 100644 --- a/res/app/menu/menu.pug +++ b/res/app/menu/menu.pug @@ -1,3 +1,7 @@ +// + Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + .navbar.stf-menu(ng-controller='MenuCtrl') .container-fluid.stf-top-bar a.stf-logo(ng-href="/#!/devices") STF @@ -9,6 +13,9 @@ a(ng-href='/#!/devices', accesskey='1') span.fa.fa-sitemap span(ng-if='!$root.basicMode', translate) Devices + a(ng-href='/#!/groups') + span.fa.fa-object-group + span(ng-if='!$root.basicMode', translate) Groups a(ng-href='/#!/settings') span.fa.fa-gears span(ng-if='!$root.basicMode', translate) Settings @@ -18,7 +25,22 @@ button(type='button', ng-model='$root.platform', uib-btn-radio="'web'", translate).btn.btn-sm.btn-default-outline Web button(type='button', ng-model='$root.platform', uib-btn-radio="'native'", translate).btn.btn-sm.btn-default-outline Native + li.stf-nav-web-native-button(ng-if='!$root.basicMode') + button.btn.btn-sm.btn-default-outline( + type='button' + ng-click='mailToSupport()') + i.fa.fa-envelope-o + span(translate) Contact Support + + li.stf-nav-web-native-button(ng-if='!$root.basicMode') + button.btn.btn-sm.btn-default-outline( + type='button' + ng-click='logout()') + i.fa.fa-sign-out + span(translate) Logout + li(ng-show='!$root.basicMode') a(ng-href='/#!/help', accesskey='6') i.fa.fa-question-circle.fa-fw | {{ "Help" | translate }} + diff --git a/res/app/settings/devices/devices-controller.js b/res/app/settings/devices/devices-controller.js new file mode 100644 index 00000000..6e68ba51 --- /dev/null +++ b/res/app/settings/devices/devices-controller.js @@ -0,0 +1,168 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const _ = require('lodash') + +module.exports = function DevicesCtrl( + $scope +, DevicesService +, SettingsService +, ItemsPerPageOptionsService +, GenericModalService +, CommonService +) { + const devicesBySerial = {} + const deviceFields = + 'model,' + + 'serial,' + + 'version,' + + 'display.height,' + + 'display.width,' + + 'manufacturer,' + + 'sdk,' + + 'abi,' + + 'cpuPlatform,' + + 'openGLESVersion,' + + 'phone.imei,' + + 'provider.name,' + + 'group.originName' + + + function publishDevice(device) { + if (!device.model) { + device.display = {} + } + else { + device.displayStr = device.display.width + 'x' + device.display.height + } + for (let i in device) { + if (device[i] === null) { + device[i] = '' + } + } + return device + } + + function addDevice(device, timeStamp) { + return CommonService.add( + $scope.devices + , devicesBySerial + , device + , 'serial' + , timeStamp) + } + + function updateDevice(device, timeStamp) { + return CommonService.update( + $scope.devices + , devicesBySerial + , device + , 'serial' + , timeStamp) + } + + function deleteDevice(serial, timeStamp) { + return CommonService.delete( + $scope.devices + , devicesBySerial + , serial + , timeStamp) + } + + function initScope() { + DevicesService.getOboeDevices('user', deviceFields, function(device) { + addDevice(device, -1) + }) + .done(function() { + $scope.$digest() + }) + } + + SettingsService.bind($scope, { + target: 'removingFilters' + , source: 'DevicesRemovingFilters' + , defaultValue: { + present: 'False' + , booked: 'False' + , annotated: 'False' + , controlled: 'False' + } + }) + $scope.devices = [] + $scope.confirmRemove = {value: true} + $scope.scopeDevicesCtrl = $scope + $scope.itemsPerPageOptions = ItemsPerPageOptionsService + SettingsService.bind($scope, { + target: 'deviceItemsPerPage' + , source: 'deviceItemsPerPage' + , defaultValue: $scope.itemsPerPageOptions[2] + }) + $scope.removingFilterOptions = ['True', 'False', 'Any'] + + $scope.removeDevice = function(serial, askConfirmation) { + if (askConfirmation) { + GenericModalService.open({ + message: 'Really delete this device?' + , type: 'Warning' + , size: 'sm' + , cancel: true + }) + .then(function() { + CommonService.errorWrapper( + DevicesService.removeDevice + , [serial, $scope.removingFilters] + ) + }) + } + else { + CommonService.errorWrapper( + DevicesService.removeDevice + , [serial, $scope.removingFilters] + ) + } + } + + $scope.removeDevices = function(search, filteredDevices, askConfirmation) { + function removeDevices() { + CommonService.errorWrapper( + DevicesService.removeDevices + , search ? + [$scope.removingFilters, filteredDevices.map(function(device) { + return device.serial + }) + .join()] : + [$scope.removingFilters] + ) + } + + if (askConfirmation) { + GenericModalService.open({ + message: 'Really delete this selection of devices?' + , type: 'Warning' + , size: 'sm' + , cancel: true + }) + .then(function() { + removeDevices() + }) + } + else { + removeDevices() + } + } + + $scope.$on('user.settings.devices.created', function(event, message) { + addDevice(message.device, message.timeStamp) + }) + + $scope.$on('user.settings.devices.deleted', function(event, message) { + deleteDevice(message.device.serial, message.timeStamp) + }) + + $scope.$on('user.settings.devices.updated', function(event, message) { + updateDevice(publishDevice(message.device), message.timeStamp) + }) + + initScope() +} diff --git a/res/app/settings/devices/devices-spec.js b/res/app/settings/devices/devices-spec.js new file mode 100644 index 00000000..b6371fc6 --- /dev/null +++ b/res/app/settings/devices/devices-spec.js @@ -0,0 +1,21 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +describe('DevicesCtrl', function() { + + beforeEach(angular.mock.module(require('./index').name)) + + var scope, ctrl + + beforeEach(inject(function($rootScope, $controller) { + scope = $rootScope.$new() + ctrl = $controller('DevicesCtrl', {$scope: scope}) + })) + + it('should ...', inject(function() { + expect(1).toEqual(1) + + })) + +}) diff --git a/res/app/settings/devices/devices.css b/res/app/settings/devices/devices.css new file mode 100644 index 00000000..11ff8c08 --- /dev/null +++ b/res/app/settings/devices/devices.css @@ -0,0 +1,65 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +.stf-devices .selectable { + user-select: text; +} + +.stf-pager-devices-total-items { + margin-top: 5px; +} + +.stf-devices .device-header { + margin-left: 10px; +} + +.stf-devices .heading .device-header-icon { + font-size: 16px; +} + +.stf-devices .device-list-icon { + margin-right: 10px; +} + +.stf-devices .device-filters-items { + margin-top: 5px; +} + +.stf-devices .device-filters-item { + margin: 0px 10px 15px 15px; +} + +.stf-devices .devices-list .device-line { + padding: 10px; + border-bottom: 1px solid #dddddd; + margin-left: 0px; +} + +.stf-devices .devices-list .device-line.device-actions { + padding-bottom: 23px; +} + +.stf-devices .device-list-details { + display: inline-block; +} + +.stf-devices .device-list-label { + font-weight: bold; + margin-right: 10px; +} + +.stf-devices .device-list-name { + color: #007aff; + font-size: 14px; + font-weight: 300; + margin: 2px 0 6px; +} + +.stf-devices .device-list-id { + font-family: Monaco, Menlo, Consolas, "Courier New", monospace; + font-size: 10px; + margin-bottom: 2px; + color: #999999; + font-weight: 300; +} diff --git a/res/app/settings/devices/devices.pug b/res/app/settings/devices/devices.pug new file mode 100644 index 00000000..59c43d1d --- /dev/null +++ b/res/app/settings/devices/devices.pug @@ -0,0 +1,137 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.fluid-height.stf-devices(ng-controller='DevicesCtrl') + .heading + i.fa.fa-mobile.device-header-icon + span(translate) Device list + + a.pull-right.btn.btn-sm(ng-href='') + i.fa.fa-question-circle.fa-fw(uib-tooltip='{{"More about Devices" | translate}}' tooltip-placement='left') + + .widget-content.padded + + nothing-to-show(icon='fa-mobile' message='{{"No Devices" | translate}}' ng-if='!devices.length') + + div(ng-if='devices.length') + ul.list-group.devices-list + li.list-group-item + .device-line.device-actions + form.form-inline.device-header + .form-group + stf-pager( + tooltip-label="{{'Device selection' | translate}}" + total-items='filteredDevices.length' + total-items-style='stf-pager-devices-total-items' + items-per-page='scopeDevicesCtrl.deviceItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='scopeDevicesCtrl.deviceCurrentPage' + items-search='search') + + button.btn.btn-xs.btn-danger.pull-right( + type='button' + uib-tooltip="{{'Remove the device selection' | translate}}" + tooltip-placement='bottom' + tooltip-popup-delay='500' + ng-disabled='!filteredDevices.length' + ng-click='removeDevices(search, filteredDevices, confirmRemove.value)') + i.fa.fa-trash-o + span(translate) Remove + + button.btn.btn-xs.btn-success.pull-right( + type='button' + uib-tooltip="{{'Enable/Disable confirmation for device removing' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='confirmRemove.value = !confirmRemove.value' + ng-class='{"btn-warning-outline": !confirmRemove.value, "btn-success": confirmRemove.value}') + i.fa.fa-lock(ng-if='confirmRemove.value') + i.fa.fa-unlock(ng-if='!confirmRemove.value') + span(translate) Confirm Remove + + button.btn.btn-xs.btn-danger-outline.pull-right( + type='button' + uib-tooltip="{{'Set filters for device removing' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='showFilters = !showFilters' + ng-class='{"btn-danger-outline": !showFilters, "btn-danger": showFilters}') + i.fa.fa-trash-o + span(translate) Filters + + li.list-group-item(ng-if='showFilters') + .device-line + .heading + i.fa.fa-trash-o + span(translate) Removing filters + + form.form-inline.device-filters-items + .form-group.device-filters-item + label.device-list-label( + translate + uib-tooltip="{{'Device presence state' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500') Present + select( + ng-model='removingFilters.present' + ng-options='option for option in removingFilterOptions') + + .form-group.device-filters-item + label.device-list-label( + translate + uib-tooltip="{{'Device booking state' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500') Booked + select( + ng-model='removingFilters.booked' + ng-options='option for option in removingFilterOptions') + + .form-group.device-filters-item + label.device-list-label( + translate + uib-tooltip="{{'Device notes state' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500') Annotated + select( + ng-model='removingFilters.annotated' + ng-options='option for option in removingFilterOptions') + + .form-group.device-filters-item + label.device-list-label( + translate + uib-tooltip="{{'Device controlling state' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500') Controlled + select( + ng-model='removingFilters.controlled' + ng-options='option for option in removingFilterOptions') + + li.list-group-item(ng-repeat="device in devices \ + | filter:search \ + | orderBy: 'model' \ + | pagedObjectsFilter:scopeDevicesCtrl:'deviceCurrentPage':'deviceItemsPerPage':'filteredDevices' \ + track by device.serial") + .device-line.device-actions + i.fa.fa-mobile.fa-2x.fa-fw.device-list-icon + .device-list-details.selectable + .device-list-name(ng-bind-template="{{device.manufacturer + ' ' + device.model}}") + .device-list-id + span(translate) Serial + span(ng-bind-template="{{::': ' + device.serial + ' - '}}") + span(translate) OS + span(ng-bind-template="{{': ' + device.version + ' - '}}") + span(translate) Screen + span(ng-bind-template="{{': ' + device.displayStr + ' - '}}") + span(translate) SDK + span(ng-bind-template="{{': ' + device.sdk + ' - '}}") + span(translate) Location + span(ng-bind-template="{{': ' + device.provider.name + ' - '}}") + span(translate) Group Origin + span(ng-bind-template="{{': ' + device.group.originName}}") + + button.btn.btn-xs.btn-danger-outline.pull-right( + type='button' + ng-click='removeDevice(device.serial, confirmRemove.value)') + i.fa.fa-trash-o + span(translate) Remove diff --git a/res/app/settings/devices/index.js b/res/app/settings/devices/index.js new file mode 100644 index 00000000..3b6a8267 --- /dev/null +++ b/res/app/settings/devices/index.js @@ -0,0 +1,18 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +require('./devices.css') + +module.exports = angular.module('stf.settings.devices', [ + require('stf/common-ui').name, + require('stf/settings').name, + require('stf/util/common').name, + require('stf/devices').name +]) + .run(['$templateCache', function($templateCache) { + $templateCache.put( + 'settings/devices/devices.pug', require('./devices.pug') + ) + }]) + .controller('DevicesCtrl', require('./devices-controller')) diff --git a/res/app/settings/general/date-format/date-format-controller.js b/res/app/settings/general/date-format/date-format-controller.js new file mode 100644 index 00000000..3f10f0c4 --- /dev/null +++ b/res/app/settings/general/date-format/date-format-controller.js @@ -0,0 +1,27 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function DateFormatCtrl( + $scope +, SettingsService +) { + + $scope.defaultDateFormat = 'M/d/yy h:mm:ss a' + SettingsService.bind($scope, { + target: 'dateFormat' + , source: 'dateFormat' + , defaultValue: $scope.defaultDateFormat + }) + + $scope.$watch( + function() { + return SettingsService.get('dateFormat') + } + , function(newvalue) { + if (typeof newvalue === 'undefined') { + SettingsService.set('dateFormat', $scope.defaultDateFormat) + } + } + ) +} diff --git a/res/app/settings/general/date-format/date-format.pug b/res/app/settings/general/date-format/date-format.pug new file mode 100644 index 00000000..24c83968 --- /dev/null +++ b/res/app/settings/general/date-format/date-format.pug @@ -0,0 +1,17 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.fluid-height(ng-controller='DateFormatCtrl') + .heading + i.fa.fa-clock-o + span(translate) Date format + .widget-content.padded + .form-horizontal + .form-group + .input-group + .input-group-addon.input-sm + i.fa.fa-clock-o( + uib-tooltip="{{'Define your own Date format' | translate}}" tooltip-placement='auto top-right' tooltip-popup-delay='500') + input.form-control.input-sm(size='30' type='text' placeholder='M/d/yy h:mm:ss a' ng-model='dateFormat') + diff --git a/res/app/settings/general/date-format/index.js b/res/app/settings/general/date-format/index.js new file mode 100644 index 00000000..bbc20d5e --- /dev/null +++ b/res/app/settings/general/date-format/index.js @@ -0,0 +1,13 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = angular.module('stf.settings.general.date-format', [ + require('stf/settings').name +]) + .run(['$templateCache', function($templateCache) { + $templateCache.put( + 'settings/general/date-format/date-format.pug', require('./date-format.pug') + ) + }]) + .controller('DateFormatCtrl', require('./date-format-controller')) diff --git a/res/app/settings/general/email-address-separator/email-address-separator-controller.js b/res/app/settings/general/email-address-separator/email-address-separator-controller.js new file mode 100644 index 00000000..6c8a940b --- /dev/null +++ b/res/app/settings/general/email-address-separator/email-address-separator-controller.js @@ -0,0 +1,27 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function EmailAddressSeparatorCtrl( + $scope +, SettingsService +) { + + $scope.defaultEmailAddressSeparator = ',' + SettingsService.bind($scope, { + target: 'emailAddressSeparator' + , source: 'emailAddressSeparator' + , defaultValue: $scope.defaultEmailAddressSeparator + }) + + $scope.$watch( + function() { + return SettingsService.get('emailAddressSeparator') + } + , function(newvalue) { + if (typeof newvalue === 'undefined') { + SettingsService.set('emailAddressSeparator', $scope.defaultEmailAddressSeparator) + } + } + ) +} diff --git a/res/app/settings/general/email-address-separator/email-address-separator.pug b/res/app/settings/general/email-address-separator/email-address-separator.pug new file mode 100644 index 00000000..7f4e59d2 --- /dev/null +++ b/res/app/settings/general/email-address-separator/email-address-separator.pug @@ -0,0 +1,17 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.fluid-height(ng-controller='EmailAddressSeparatorCtrl') + .heading + i.fa.fa-envelope-o + span(translate) Email address separator + .widget-content.padded + .form-horizontal + .form-group + .input-group + .input-group-addon.input-sm + i.fa.fa-envelope-o( + uib-tooltip="{{'Define your own Email address separator' | translate}}" tooltip-placement='auto top-right' tooltip-popup-delay='500') + input.form-control.input-sm(size='2' type='text' placeholder=',' ng-model='emailAddressSeparator') + diff --git a/res/app/settings/general/email-address-separator/index.js b/res/app/settings/general/email-address-separator/index.js new file mode 100644 index 00000000..3e2cc4fa --- /dev/null +++ b/res/app/settings/general/email-address-separator/index.js @@ -0,0 +1,14 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = angular.module('stf.settings.general.email-address-separator', [ + require('stf/settings').name +]) + .run(['$templateCache', function($templateCache) { + $templateCache.put( + 'settings/general/email-address-separator/email-address-separator.pug' + , require('./email-address-separator.pug') + ) + }]) + .controller('EmailAddressSeparatorCtrl', require('./email-address-separator-controller')) diff --git a/res/app/settings/general/general.pug b/res/app/settings/general/general.pug index 26a26a96..0fa05bdb 100644 --- a/res/app/settings/general/general.pug +++ b/res/app/settings/general/general.pug @@ -1,5 +1,13 @@ +// + Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + .row - .col-md-6 + .col-md-3 div(ng-include='"settings/general/local/local-settings.pug"') - .col-md-6 + .col-md-3 div(ng-include='"settings/general/language/language.pug"') + .col-md-3 + div(ng-include='"settings/general/date-format/date-format.pug"') + .col-md-3 + div(ng-include='"settings/general/email-address-separator/email-address-separator.pug"') diff --git a/res/app/settings/general/index.js b/res/app/settings/general/index.js index f2df94da..5033197a 100644 --- a/res/app/settings/general/index.js +++ b/res/app/settings/general/index.js @@ -1,8 +1,14 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + require('./general.css') module.exports = angular.module('stf.settings.general', [ require('./language').name, - require('./local').name + require('./local').name, + require('./email-address-separator').name, + require('./date-format').name ]) .run(['$templateCache', function($templateCache) { $templateCache.put( diff --git a/res/app/settings/groups/conflicts/conflicts.pug b/res/app/settings/groups/conflicts/conflicts.pug new file mode 100644 index 00000000..c250317f --- /dev/null +++ b/res/app/settings/groups/conflicts/conflicts.pug @@ -0,0 +1,31 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +li.list-group-item.groups-list + .heading.group-action-body + i.fa.fa-ban + span(translate) Conflicts + + .widget-container.fluid-height.overflow-auto.group-conflicts + table.table.table-hover.dataTable.ng-table + thead + tr + th.header.sortable( + ng-class='[column.sort]' + ng-repeat='column in conflictData.columns' + ng-click='sortBy(conflictData, column)') + div.strong(ng-bind-template='{{column.name | translate}}') + + tbody + tr.selectable( + ng-repeat='conflict in groupsEnv[group.id].conflicts \ + | orderBy:conflictColumns[conflictData.sort.index].property:conflictData.sort.reverse') + td {{conflict.serial}} + td {{conflict.startDate}} + td {{conflict.stopDate}} + td {{conflict.group}} + td + a.link(ng-href="{{'mailto:' + conflict.ownerEmail}}" + ng-click='$event.stopPropagation()') {{conflict.ownerName}} + diff --git a/res/app/settings/groups/devices/devices.pug b/res/app/settings/groups/devices/devices.pug new file mode 100644 index 00000000..4bffbfd0 --- /dev/null +++ b/res/app/settings/groups/devices/devices.pug @@ -0,0 +1,195 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +li.list-group-item.groups-list + .heading.group-action-body + i.fa.fa-mobile + span(translate) Devices + + .row + .panel-group + .panel.panel-default + .panel-heading.text-center + button.btn.btn-xs.btn-primary.btn-group-devices-action( + type='button' + ng-click='showGroupDevices = !showGroupDevices' + ng-class='{"btn-primary-outline": showGroupDevices, "btn-primary": !showGroupDevices}') + i.fa.fa-mobile + span(translate) Group devices + + .panel-body(ng-show='!showGroupDevices') + nothing-to-show( + icon='fa-mobile' message='{{"No group devices" | translate}}' + ng-if='!groupsEnv[group.id].filteredGroupDevices.length && \ + (!groupsEnv[group.id].availableDevices.length || !group.devices.length)') + + div(ng-show='groupsEnv[group.id].filteredGroupDevices.length || \ + groupsEnv[group.id].availableDevices.length && group.devices.length') + .form-inline + .form-group.group-devices-header + stf-pager( + tooltip-label="{{'Group device selection' | translate}}" + total-items='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupDevices.length' + total-items-style='stf-pager-groups-total-items' + items-per-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].groupDeviceItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].groupDeviceCurrentPage' + items-search='groupDeviceSearch') + + .form-group.group-devices-header + stf-column-choice( + button-style='margin: 5px 0px 0px 15px' + reset-data='resetGroupDeviceData()' + column-data='groupDeviceData.columns') + + .widget-container.fluid-height.overflow-auto + table.table.table-hover.dataTable.ng-table + thead + tr + th.header + button.btn.btn-sm.btn-danger.btn-group-devices-action( + type='button' + ng-if="groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].hasOwnProperty('filteredGroupDevices')" + ng-disabled="!groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupDevices.length || \ + group.privilege === 'root'" + ng-click='removeGroupDevices(\ + group, \ + groupDeviceSearch, \ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupDevices)') + i.fa.fa-trash-o + th.header.sortable( + ng-class='[column.sort]' + ng-repeat='column in groupDeviceData.columns | filter: {selected: true}' + ng-click='sortBy(groupDeviceData, column)') + div.strong(ng-bind-template="{{column.name | translate}}") + + tbody + tr.selectable(ng-repeat="device in filteredGroups[getGroupIndex($parent.$index)].devices \ + | groupObjectsFilter:\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].availableDevices:\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].availableDevicesBySerial \ + | filter:groupDeviceSearch \ + | orderBy:deviceColumns[groupDeviceData.sort.index].property:groupDeviceData.sort.reverse \ + | pagedObjectsFilter:\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id]:\ + 'groupDeviceCurrentPage':'groupDeviceItemsPerPage':'filteredGroupDevices' \ + track by device.serial") + td + button.btn.btn-danger-outline.btn-xs( + ng-disabled="filteredGroups[getGroupIndex($parent.$index)].privilege === 'root'" + ng-click='removeGroupDevice(filteredGroups[getGroupIndex($parent.$index)], device)') + i.fa.fa-trash-o.fa-fw + td(ng-if='groupDeviceData.columns[0].selected') {{device.model}} + td(ng-if='groupDeviceData.columns[1].selected') {{device.serial}} + td(ng-if='groupDeviceData.columns[2].selected') {{device.operator}} + td(ng-if='groupDeviceData.columns[3].selected') {{device.version}} + td(ng-if='groupDeviceData.columns[4].selected') {{device.networkStr}} + td(ng-if='groupDeviceData.columns[5].selected') {{device.displayStr}} + td(ng-if='groupDeviceData.columns[6].selected') {{device.manufacturer}} + td(ng-if='groupDeviceData.columns[7].selected') {{device.sdk}} + td(ng-if='groupDeviceData.columns[8].selected') {{device.abi}} + td(ng-if='groupDeviceData.columns[9].selected') {{device.cpuPlatform}} + td(ng-if='groupDeviceData.columns[10].selected') {{device.openGLESVersion}} + td(ng-if='groupDeviceData.columns[11].selected') {{device.phone.imei}} + td(ng-if='groupDeviceData.columns[12].selected') {{device.provider.name}} + td(ng-if='groupDeviceData.columns[13].selected') {{device.group.originName}} + + .panel.panel-default + .panel-heading.text-center + button.btn.btn-xs.btn-primary-outline.btn-group-devices-action( + type='button' + ng-click='showAvailableDevices = !showAvailableDevices' + ng-class='{"btn-primary-outline": !showAvailableDevices, "btn-primary": showAvailableDevices}') + i.fa.fa-mobile + span(translate) Available devices + + .panel-body(ng-show='showAvailableDevices') + nothing-to-show( + icon='fa-mobile' message='{{"No available devices" | translate}}' + ng-if='!(groupsEnv[group.id].filteredAvailableDevices && \ + groupsEnv[group.id].filteredAvailableDevices.length || \ + groupsEnv[group.id].availableDevices.length !== group.devices.length)') + + div(ng-if='groupsEnv[group.id].filteredAvailableDevices && \ + groupsEnv[group.id].filteredAvailableDevices.length || \ + groupsEnv[group.id].availableDevices.length !== group.devices.length') + .form-inline + .form-group.group-devices-header + stf-pager( + tooltip-label="{{'Available device selection' | translate}}" + total-items='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableDevices.length' + total-items-style='stf-pager-groups-total-items' + items-per-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].availableDeviceItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].availableDeviceCurrentPage' + items-search='deviceSearch') + + .form-group.group-devices-header + stf-column-choice( + button-style='margin: 5px 0px 0px 15px' + reset-data='resetDeviceData()' + column-data='deviceData.columns') + + .widget-container.fluid-height.overflow-auto + table.table.table-hover.dataTable.ng-table + thead + tr + th.header + button.btn.btn-sm.btn-primary.btn-group-devices-action( + type='button' + ng-if="groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].hasOwnProperty('filteredAvailableDevices')" + ng-disabled='!groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableDevices.length || \ + !conditionForDevicesAddition(\ + filteredGroups[getGroupIndex($parent.$index)], \ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableDevices.length)' + uib-tooltip="{{'Groups duration quota is reached' | translate}}" + tooltip-placement='auto top-right' + tooltip-enable="groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableDevices.length && \ + !conditionForDevicesAddition(\ + filteredGroups[getGroupIndex($parent.$index)], \ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableDevices.length)" + tooltip-popup-delay='500' + ng-click='addGroupDevices(group, \ + deviceSearch, \ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableDevices)') + i.fa.fa-cart-plus + th.header.sortable( + ng-class='[column.sort]' + ng-repeat="column in deviceData.columns | filter: {selected: true}" + ng-click='sortBy(deviceData, column)') + div.strong(ng-bind-template="{{column.name | translate}}") + + tbody + tr.selectable(ng-repeat="device in groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].availableDevices \ + | availableObjectsFilter:filteredGroups[getGroupIndex($parent.$index)]:'devices':'serial' \ + | filter:deviceSearch \ + | orderBy:deviceColumns[deviceData.sort.index].property:deviceData.sort.reverse \ + | pagedObjectsFilter:\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id]:\ + 'availableDeviceCurrentPage':'availableDeviceItemsPerPage':'filteredAvailableDevices' \ + track by device.serial") + td + button.btn.btn-primary-outline.btn-xs( + type='button' + ng-disabled='!conditionForDevicesAddition(filteredGroups[getGroupIndex($parent.$index)], 1)' + uib-tooltip="{{'Groups duration quota is reached' | translate}}" + tooltip-placement='auto top-right' + tooltip-enable="!conditionForDevicesAddition(filteredGroups[getGroupIndex($parent.$index)], 1)" + tooltip-popup-delay='500' + ng-click='addGroupDevice(filteredGroups[getGroupIndex($parent.$index)], device)') + i.fa.fa-cart-plus.fa-fw + td(ng-if='deviceData.columns[0].selected') {{device.model}} + td(ng-if='deviceData.columns[1].selected') {{device.serial}} + td(ng-if='deviceData.columns[2].selected') {{device.operator}} + td(ng-if='deviceData.columns[3].selected') {{device.version}} + td(ng-if='deviceData.columns[4].selected') {{device.networkStr}} + td(ng-if='deviceData.columns[5].selected') {{device.displayStr}} + td(ng-if='deviceData.columns[6].selected') {{device.manufacturer}} + td(ng-if='deviceData.columns[7].selected') {{device.sdk}} + td(ng-if='deviceData.columns[8].selected') {{device.abi}} + td(ng-if='deviceData.columns[9].selected') {{device.cpuPlatform}} + td(ng-if='deviceData.columns[10].selected') {{device.openGLESVersion}} + td(ng-if='deviceData.columns[11].selected') {{device.phone.imei}} + td(ng-if='deviceData.columns[12].selected') {{device.provider.name}} + td(ng-if='deviceData.columns[13].selected') {{device.group.originName}} diff --git a/res/app/settings/groups/filters/available-objects-filter.js b/res/app/settings/groups/filters/available-objects-filter.js new file mode 100644 index 00000000..54b1bb41 --- /dev/null +++ b/res/app/settings/groups/filters/available-objects-filter.js @@ -0,0 +1,17 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function() { + return function(objects, group, groupKey, objectKey) { + const objectList = [] + + objects.forEach(function(object) { + if (group[groupKey].indexOf(object[objectKey]) < 0) { + objectList.push(object) + } + }) + return objectList + } +} + diff --git a/res/app/settings/groups/filters/group-objects-filter.js b/res/app/settings/groups/filters/group-objects-filter.js new file mode 100644 index 00000000..f102b001 --- /dev/null +++ b/res/app/settings/groups/filters/group-objects-filter.js @@ -0,0 +1,17 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function(CommonService) { + return function(keys, objects, objectsIndex) { + const objectList = [] + + keys.forEach(function(key) { + if (CommonService.isExisting(objectsIndex[key])) { + objectList.push(objects[objectsIndex[key].index]) + } + }) + return objectList + } +} + diff --git a/res/app/settings/groups/groups-controller.js b/res/app/settings/groups/groups-controller.js new file mode 100644 index 00000000..de89dccc --- /dev/null +++ b/res/app/settings/groups/groups-controller.js @@ -0,0 +1,908 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const _ = require('lodash') +const Promise = require('bluebird') + +module.exports = function GroupsCtrl( + $scope +, $filter +, GroupsService +, UserService +, UsersService +, DevicesService +, SettingsService +, ItemsPerPageOptionsService +, GenericModalService +, CommonService +) { + const originDevices = [] + const originDevicesBySerial = {} + const standardizableDevices = [] + const standardizableDevicesBySerial = {} + const groupsById = {} + const cachedGroupsClass = {} + const deviceFields = + 'serial,' + + 'model,' + + 'version,' + + 'operator,' + + 'network.type,' + + 'network.subtype,' + + 'display.height,' + + 'display.width,' + + 'manufacturer,' + + 'sdk,' + + 'abi,' + + 'cpuPlatform,' + + 'openGLESVersion,' + + 'phone.imei,' + + 'provider.name,' + + 'group.originName' + const userFields = + 'email,' + + 'name,' + + 'privilege,' + + 'groups.subscribed,' + + 'groups.quotas.allocated,' + + 'groups.quotas.consumed' + let rootGroupId + + function publishDevice(device) { + if (!device.model) { + device.display = device.phone = device.network = {} + } + else { + device.displayStr = device.display.width + 'x' + device.display.height + device.networkStr = $scope.computeNetwork(device) + } + return device + } + + function initAvailableGroupDevices(group, availableDevices, availableDevicesBySerial) { + $scope.groupsEnv[group.id].availableDevices = availableDevices + $scope.groupsEnv[group.id].availableDevicesBySerial = availableDevicesBySerial + $scope.groupsEnv[group.id].availableDevices.forEach(function(device) { + publishDevice(device) + }) + } + + function getAvailableGroupDevices(group) { + if (group.class === 'bookable') { + initAvailableGroupDevices(group, originDevices, originDevicesBySerial) + } + else if (group.class === 'standard') { + initAvailableGroupDevices(group, standardizableDevices, standardizableDevicesBySerial) + } + else if ($scope.groupsEnv[group.id].showDevices) { + GroupsService.getGroupDevices(group.id, true, deviceFields).then(function(response) { + if (CommonService.isExisting($scope.groupsEnv[group.id])) { + $scope.groupsEnv[group.id].availableDevicesBySerial = {} + $scope.groupsEnv[group.id].availableDevices = [] + response.data.devices.forEach(function(device) { + addAvailableGroupDevice(group.id, device, -1) + }) + initAvailableGroupDevices( + group + , $scope.groupsEnv[group.id].availableDevices + , $scope.groupsEnv[group.id].availableDevicesBySerial) + } + }) + } + } + + function checkDurationQuota(group, deviceNumber, startDate, stopDate, repetitions) { + if (CommonService.isOriginGroup(group.class)) { + return true + } + if (CommonService.isExisting($scope.usersByEmail[group.owner.email])) { + const duration = + (group.devices.length + deviceNumber) * + ((new Date(stopDate)) - (new Date(startDate))) * + (repetitions + 1) + + if (duration <= + $scope.users[$scope.usersByEmail[group.owner.email].index] + .groups.quotas.allocated.duration) { + return true + } + } + return false + } + + function isBookedDevice(serial) { + if (CommonService.isExisting(originDevicesBySerial[serial])) { + for(let i in $scope.groups) { + if (!CommonService.isOriginGroup($scope.groups[i].class) && + $scope.groups[i].devices.indexOf(serial) > -1) { + return true + } + } + } + return false + } + + function addStandardizableDevicesIfNotBooked(devices, timeStamp) { + devices.forEach(function(serial) { + if (!isBookedDevice(serial)) { + addStandardizableDevice( + originDevices[originDevicesBySerial[serial].index] + , timeStamp + ) + } + }) + } + + function updateStandardizableDeviceIfNotBooked(device, timeStamp) { + if (!isBookedDevice(device.serial)) { + updateStandardizableDevice(device, timeStamp) + } + } + + function initGroup(group) { + cachedGroupsClass[group.id] = group.class + if (typeof $scope.groupsEnv[group.id] === 'undefined') { + $scope.groupsEnv[group.id] = {} + initAvailableGroupDevices(group, [], {}) + if (group.privilege === 'root') { + rootGroupId = group.id + } + } + return group + } + + function addGroup(group, timeStamp) { + if (CommonService.add($scope.groups, groupsById, group, 'id', timeStamp)) { + return initGroup(group) + } + return null + } + + function updateGroup(group, timeStamp, noAdding) { + if (CommonService.update($scope.groups, groupsById, group, 'id', timeStamp, noAdding)) { + return initGroup($scope.groups[groupsById[group.id].index]) + } + return null + } + + function deleteGroup(id, timeStamp) { + const group = CommonService.delete($scope.groups, groupsById, id, timeStamp) + + if (group) { + delete $scope.groupsEnv[group.id] + } + return group + } + + function addOriginDevice(device, timeStamp) { + return CommonService.add(originDevices, originDevicesBySerial, device, 'serial', timeStamp) + } + + function updateOriginDevice(device, timeStamp) { + return CommonService.update(originDevices, originDevicesBySerial, device, 'serial', timeStamp) + } + + function deleteOriginDevice(serial, timeStamp) { + return CommonService.delete(originDevices, originDevicesBySerial, serial, timeStamp) + } + + function addStandardizableDevice(device, timeStamp) { + return CommonService.add( + standardizableDevices, standardizableDevicesBySerial, device, 'serial', timeStamp) + } + + function updateStandardizableDevice(device, timeStamp) { + return CommonService.update( + standardizableDevices, standardizableDevicesBySerial, device, 'serial', timeStamp) + } + + function deleteStandardizableDevice(serial, timeStamp) { + return CommonService.delete( + standardizableDevices, standardizableDevicesBySerial, serial, timeStamp) + } + + function addAvailableGroupDevice(id, device, timeStamp) { + return CommonService.add( + $scope.groupsEnv[id].availableDevices + , $scope.groupsEnv[id].availableDevicesBySerial, device, 'serial', timeStamp) + } + + function updateAvailableGroupDevice(id, device, timeStamp, noAdding) { + return CommonService.update( + $scope.groupsEnv[id].availableDevices + , $scope.groupsEnv[id].availableDevicesBySerial, device, 'serial', timeStamp, noAdding) + } + + function deleteAvailableGroupDevice(id, serial, timeStamp) { + return CommonService.delete( + $scope.groupsEnv[id].availableDevices + , $scope.groupsEnv[id].availableDevicesBySerial, serial, timeStamp) + } + + function addUser(user, timeStamp) { + return CommonService.add($scope.users, $scope.usersByEmail, user, 'email', timeStamp) + } + + function updateUser(user, timeStamp) { + return CommonService.update($scope.users, $scope.usersByEmail, user, 'email', timeStamp) + } + + function deleteUser(email, timeStamp) { + return CommonService.delete($scope.users, $scope.usersByEmail, email, timeStamp) + } + + function initScope() { + GroupsService.getOboeMyGroups(function(group) { + addGroup(group, -1) + }) + .done(function() { + $scope.$digest() + }) + + UsersService.getOboeUsers(userFields, function(user) { + addUser(user, -1) + }) + .done(function() { + if (CommonService.isExisting($scope.usersByEmail[$scope.currentUser.email])) { + $scope.users[$scope.usersByEmail[$scope.currentUser.email].index] = $scope.currentUser + } + }) + + UserService.getUser().then(function(response) { + CommonService.merge($scope.currentUser, response.data.user) + }) + + if ($scope.isAdmin()) { + DevicesService.getOboeDevices('origin', deviceFields, function(device) { + addOriginDevice(device, -1) + }) + DevicesService.getOboeDevices('standardizable', deviceFields, function(device) { + addStandardizableDevice(device, -1) + }) + } + } + + $scope.currentUser = CommonService.merge({}, UserService.currentUser) + $scope.users = [] + $scope.usersByEmail = {} + $scope.groups = [] + $scope.groupsEnv = {} + $scope.confirmRemove = {value: true} + $scope.scopeGroupsCtrl = $scope + $scope.itemsPerPageOptions = ItemsPerPageOptionsService + + SettingsService.bind($scope, { + target: 'groupItemsPerPage' + , source: 'groupItemsPerPage' + , defaultValue: $scope.itemsPerPageOptions[2] + }) + + $scope.userColumns = [ + {name: 'Name', property: 'name'} + , {name: 'Email', property: 'email'} + , {name: 'Privilege', property: 'privilege'} + ] + $scope.defaultUserData = { + columns: [ + {name: 'Name', sort: 'sort-asc'} + , {name: 'Email', sort: 'none'} + , {name: 'Privilege', sort: 'none'} + ] + , sort: {index: 0, reverse: false} + } + SettingsService.bind($scope, { + target: 'userData' + , source: 'userData' + , defaultValue: $scope.defaultUserData + }) + SettingsService.bind($scope, { + target: 'groupUserData' + , source: 'groupUserData' + , defaultValue: $scope.defaultUserData + }) + + $scope.conflictColumns = [ + {name: 'Serial', property: 'serial'} + , {name: 'Starting Date', property: 'startDate'} + , {name: 'Expiration Date', property: 'stopDate'} + , {name: 'Group Name', property: 'group'} + , {name: 'Group Owner', property: 'ownerName'} + ] + $scope.defaultConflictData = { + columns: [ + {name: 'Serial', sort: 'sort-asc'} + , {name: 'Starting Date', sort: 'none'} + , {name: 'Expiration Date', sort: 'none'} + , {name: 'Group Name', sort: 'none'} + , {name: 'Group Owner', sort: 'none'} + ] + , sort: {index: 0, reverse: false} + } + SettingsService.bind($scope, { + target: 'conflictData' + , source: 'conflictData' + , defaultValue: $scope.defaultConflictData + }) + + $scope.mailToGroupOwners = function(groups) { + CommonService.copyToClipboard(_.uniq(groups.map(function(group) { + return group.owner.email + })) + .join(SettingsService.get('emailSeparator'))) + .url('mailto:?body=*** Paste the email addresses from the clipboard! ***') + } + + $scope.mailToGroupUsers = function(group, users) { + // group unused actually.. + $scope.mailToAvailableUsers(users) + } + + $scope.mailToAvailableUsers = function(users) { + CommonService.copyToClipboard(users.map(function(user) { + return user.email + }) + .join(SettingsService.get('emailSeparator'))) + .url('mailto:?body=*** Paste the email addresses from the clipboard! ***') + } + + $scope.getGroupIndex = function(relativeIndex) { + return relativeIndex + ($scope.groupCurrentPage - 1) * $scope.groupItemsPerPage.value + } + + $scope.computeDisplay = function(device) { + return device.display.width * device.display.height + } + + $scope.computeNetwork = function(device) { + if (!device.network || !device.network.type) { + return '' + } + else if (device.network.subtype) { + return device.network.type + ' (' + device.network.subtype + ')' + } + return device.network.type + } + + $scope.resetDeviceData = function() { + $scope.deviceData = JSON.parse(JSON.stringify($scope.defaultDeviceData)) + } + + $scope.resetGroupDeviceData = function() { + $scope.groupDeviceData = JSON.parse(JSON.stringify($scope.defaultDeviceData)) + } + + $scope.deviceColumns = [ + {name: 'Model', property: 'model'} + , {name: 'Serial', property: 'serial'} + , {name: 'Carrier', property: 'operator'} + , {name: 'OS', property: 'version'} + , {name: 'Network', property: $scope.computeNetwork} + , {name: 'Screen', property: $scope.computeDisplay} + , {name: 'Manufacturer', property: 'manufacturer'} + , {name: 'SDK', property: 'sdk'} + , {name: 'ABI', property: 'abi'} + , {name: 'CPU Platform', property: 'cpuPlatform'} + , {name: 'OpenGL ES version', property: 'openGLESVersion'} + , {name: 'Phone IMEI', property: 'phone.imei'} + , {name: 'Location', property: 'provider.name'} + , {name: 'Group Origin', property: 'group.originName'} + ] + $scope.defaultDeviceData = { + columns: [ + {name: 'Model', selected: true, sort: 'sort-asc'} + , {name: 'Serial', selected: true, sort: 'none'} + , {name: 'Carrier', selected: false, sort: 'none'} + , {name: 'OS', selected: true, sort: 'none'} + , {name: 'Network', selected: false, sort: 'none'} + , {name: 'Screen', selected: true, sort: 'none'} + , {name: 'Manufacturer', selected: true, sort: 'none'} + , {name: 'SDK', selected: true, sort: 'none'} + , {name: 'ABI', selected: false, sort: 'none'} + , {name: 'CPU Platform', selected: false, sort: 'none'} + , {name: 'OpenGL ES version', selected: false, sort: 'none'} + , {name: 'Phone IMEI', selected: false, sort: 'none'} + , {name: 'Location', selected: true, sort: 'none'} + , {name: 'Group Origin', selected: true, sort: 'none'} + ] + , sort: {index: 0, reverse: false} + } + SettingsService.bind($scope, { + target: 'deviceData' + , source: 'deviceData' + , defaultValue: $scope.defaultDeviceData + }) + SettingsService.bind($scope, { + target: 'groupDeviceData' + , source: 'groupDeviceData' + , defaultValue: $scope.defaultDeviceData + }) + $scope.nameRegex = /^[0-9a-zA-Z-_./: ]{1,50}$/ + $scope.nameRegexStr = '/^[0-9a-zA-Z-_./: ]{1,50}$/' + $scope.classOptions = CommonService.classOptions + $scope.getClassName = CommonService.getClassName + $scope.sortBy = CommonService.sortBy + + $scope.isAdmin = function() { + return $scope.currentUser.privilege === 'admin' + } + + $scope.getRepetitionsQuotas = function(email) { + if (CommonService.isExisting($scope.usersByEmail[email])) { + return $scope.users[$scope.usersByEmail[email].index].groups.quotas.repetitions + } + return null + } + + $scope.initShowDevices = function(group, showDevices) { + if (typeof $scope.groupsEnv[group.id].groupDeviceCurrentPage === 'undefined') { + $scope.groupsEnv[group.id].groupDeviceCurrentPage = 1 + $scope.groupsEnv[group.id].groupDeviceItemsPerPage = $scope.itemsPerPageOptions[1] + $scope.groupsEnv[group.id].availableDeviceCurrentPage = 1 + $scope.groupsEnv[group.id].availableDeviceItemsPerPage = $scope.itemsPerPageOptions[1] + } + $scope.groupsEnv[group.id].showDevices = showDevices + getAvailableGroupDevices(group) + } + + $scope.initShowUsers = function(group) { + if (typeof $scope.groupsEnv[group.id].groupUserCurrentPage === 'undefined') { + $scope.groupsEnv[group.id].groupUserCurrentPage = 1 + $scope.groupsEnv[group.id].groupUserItemsPerPage = $scope.itemsPerPageOptions[1] + $scope.groupsEnv[group.id].availableUserCurrentPage = 1 + $scope.groupsEnv[group.id].availableUserItemsPerPage = $scope.itemsPerPageOptions[1] + } + } + + $scope.watchGroupClass = function(group) { + if (CommonService.isNoRepetitionsGroup($scope.groupsEnv[group.id].tmpClass)) { + $scope.groupsEnv[group.id].tmpRepetitions = 0 + } + else if ($scope.groupsEnv[group.id].tmpRepetitions === 0) { + $scope.groupsEnv[group.id].tmpRepetitions = 1 + } + } + + $scope.initTemporaryName = function(group) { + $scope.groupsEnv[group.id].tmpName = group.name + $scope.groupsEnv[group.id].tmpNameTooltip = 'No change' + } + + $scope.initTemporarySchedule = function(group) { + $scope.groupsEnv[group.id].tmpClass = group.class + $scope.groupsEnv[group.id].tmpRepetitions = group.repetitions + $scope.groupsEnv[group.id].tmpStartDate = new Date(group.dates[0].start) + $scope.groupsEnv[group.id].tmpStopDate = new Date(group.dates[0].stop) + $scope.groupsEnv[group.id].tmpScheduleTooltip = 'No change' + } + + $scope.conditionForDevicesAddition = function(group, deviceNumber) { + return checkDurationQuota( + group + , deviceNumber + , group.dates[0].start + , group.dates[0].stop + , group.repetitions + ) + } + + $scope.conditionForGroupCreation = function() { + return $scope.currentUser.groups.quotas.consumed.number < + $scope.currentUser.groups.quotas.allocated.number + } + + $scope.conditionForGroupUsersRemoving = function(group, users) { + return !(users.length === 0 || + group.privilege === 'root' && users.length === 1 && users[0].privilege === 'admin' || + group.privilege !== 'root' && + (users.length === 2 && + (users[0].privilege === 'admin' && users[1].email === group.owner.email || + users[0].email === group.owner.email && users[1].privilege === 'admin') || + users.length === 1 && + (users[0].email === group.owner.email || users[0].privilege === 'admin')) + ) + } + + $scope.conditionForNameSaving = function(group, formInvalidStatus) { + return !formInvalidStatus && $scope.groupsEnv[group.id].tmpName !== group.name + } + + $scope.conditionForScheduleSaving = function(group, formInvalidStatus) { + if (formInvalidStatus) { + $scope.groupsEnv[group.id].tmpScheduleTooltip = 'Bad syntax' + return false + } + if ($scope.groupsEnv[group.id].tmpClass !== group.class || + parseInt($scope.groupsEnv[group.id].tmpRepetitions, 10) !== group.repetitions || + $scope.groupsEnv[group.id].tmpStartDate.getTime() !== + (new Date(group.dates[0].start)).getTime() || + $scope.groupsEnv[group.id].tmpStopDate.getTime() !== + (new Date(group.dates[0].stop)).getTime()) { + if (!CommonService.isNoRepetitionsGroup($scope.groupsEnv[group.id].tmpClass)) { + if (parseInt($scope.groupsEnv[group.id].tmpRepetitions, 10) === 0) { + $scope.groupsEnv[group.id].tmpScheduleTooltip = 'Repetitions must be > 0 for this Class' + return false + } + } + if ($scope.groupsEnv[group.id].tmpStartDate >= $scope.groupsEnv[group.id].tmpStopDate) { + $scope.groupsEnv[group.id].tmpScheduleTooltip = 'Starting date >= Expiration date' + return false + } + if (($scope.groupsEnv[group.id].tmpStopDate - $scope.groupsEnv[group.id].tmpStartDate) > + CommonService.getClassDuration($scope.groupsEnv[group.id].tmpClass)) { + $scope.groupsEnv[group.id].tmpScheduleTooltip = + '(Expiration date - Starting date) must be <= Class duration' + return false + } + if ($scope.isAdmin() && + group.devices.length && + (CommonService.isOriginGroup(group.class) && + !CommonService.isOriginGroup($scope.groupsEnv[group.id].tmpClass) || + CommonService.isOriginGroup($scope.groupsEnv[group.id].tmpClass) && + !CommonService.isOriginGroup(group.class))) { + $scope.groupsEnv[group.id].tmpScheduleTooltip = + 'Unauthorized class while device list is not empty' + return false + } + if (!checkDurationQuota( + group + , 0 + , $scope.groupsEnv[group.id].tmpStartDate + , $scope.groupsEnv[group.id].tmpStopDate + , $scope.groupsEnv[group.id].tmpRepetitions)) { + $scope.groupsEnv[group.id].tmpScheduleTooltip = 'Group duration quotas is reached' + return false + } + $scope.groupsEnv[group.id].tmpScheduleTooltip = '' + return true + } + $scope.groupsEnv[group.id].tmpScheduleTooltip = 'No change' + return false + } + + $scope.conditionForRepetitions = function(group) { + return !CommonService.isNoRepetitionsGroup($scope.groupsEnv[group.id].tmpClass) + } + + $scope.addGroupDevice = function(group, device) { + if (CommonService.isOriginGroup(group.class)) { + CommonService.errorWrapper( + DevicesService.addOriginGroupDevice + , [group.id, device.serial]) + } + else { + CommonService.errorWrapper( + GroupsService.addGroupDevice + , [group.id, device.serial]) + .then(function(response) { + if (!response.success && + response.status === 409 && + response.data.hasOwnProperty('conflicts')) { + $scope.groupsEnv[group.id].showConflicts = true + $scope.groupsEnv[group.id].conflicts = response.data.conflicts + } + }) + } + } + + $scope.addGroupDevices = function(group, deviceSearch, filteredDevices) { + CommonService.errorWrapper( + CommonService.isOriginGroup(group.class) ? + DevicesService.addOriginGroupDevices : + GroupsService.addGroupDevices + , deviceSearch ? + [group.id, filteredDevices.map(function(device) { return device.serial }).join()] : + [group.id]) + } + + $scope.removeGroupDevice = function(group, device) { + CommonService.errorWrapper( + CommonService.isOriginGroup(group.class) ? + DevicesService.removeOriginGroupDevice : + GroupsService.removeGroupDevice + , [group.id, device.serial]) + } + + $scope.removeGroupDevices = function(group, deviceSearch, filteredDevices) { + CommonService.errorWrapper( + CommonService.isOriginGroup(group.class) ? + DevicesService.removeOriginGroupDevices : + GroupsService.removeGroupDevices + , deviceSearch ? + [group.id, filteredDevices.map(function(device) { return device.serial }).join()] : + [group.id]) + } + + $scope.addGroupUser = function(group, user) { + CommonService.errorWrapper( + GroupsService.addGroupUser + , [group.id, user.email]) + } + + $scope.addGroupUsers = function(group, userSearch, filteredUsers) { + CommonService.errorWrapper( + GroupsService.addGroupUsers + , userSearch ? + [group.id, filteredUsers.map(function(user) { return user.email }).join()] : + [group.id]) + } + + $scope.removeGroupUser = function(group, user) { + CommonService.errorWrapper( + GroupsService.removeGroupUser + , [group.id, user.email]) + } + + $scope.removeGroupUsers = function(group, userSearch, filteredUsers) { + CommonService.errorWrapper( + GroupsService.removeGroupUsers + , userSearch ? + [group.id, filteredUsers.map(function(user) { return user.email }).join()] : + [group.id]) + } + + $scope.removeGroup = function(group, askConfirmation) { + if (askConfirmation) { + GenericModalService.open({ + message: 'Really delete this group?' + , type: 'Warning' + , size: 'sm' + , cancel: true + }) + .then(function() { + CommonService.errorWrapper( + GroupsService.removeGroup + , [group.id]) + }) + } + else { + CommonService.errorWrapper( + GroupsService.removeGroup + , [group.id]) + } + } + + $scope.removeGroups = function(search, filteredGroups, askConfirmation) { + function removeGroups() { + if (!search) { + CommonService.errorWrapper(GroupsService.removeGroups) + } + else { + CommonService.errorWrapper( + GroupsService.removeGroups + , [filteredGroups.map(function(group) { return group.id }).join()]) + } + } + + if (askConfirmation) { + GenericModalService.open({ + message: 'Really delete this selection of groups?' + , type: 'Warning' + , size: 'sm' + , cancel: true + }) + .then(function() { + removeGroups() + }) + } + else { + removeGroups() + } + } + + $scope.createGroup = function() { + $scope.hideGroupCreation = true + CommonService.errorWrapper(GroupsService.createGroup) + .then(function() { + delete $scope.hideGroupCreation + }) + } + + $scope.updateGroupSchedule = function(group) { + CommonService.errorWrapper(GroupsService.updateGroup, [group.id, { + 'class': $scope.groupsEnv[group.id].tmpClass + , 'repetitions': parseInt($scope.groupsEnv[group.id].tmpRepetitions, 10) + , 'startTime': $scope.groupsEnv[group.id].tmpStartDate + , 'stopTime': $scope.groupsEnv[group.id].tmpStopDate + }]) + .then(function(response) { + if (!response.success && + response.status === 409 && + response.data.hasOwnProperty('conflicts')) { + $scope.groupsEnv[group.id].conflicts = [] + response.data.conflicts.forEach(function(conflict) { + conflict.devices.forEach(function(serial) { + $scope.groupsEnv[group.id].conflicts.push({ + serial: serial + , startDate: $filter('date')(conflict.date.start, SettingsService.get('dateFormat')) + , stopDate: $filter('date')(conflict.date.stop, SettingsService.get('dateFormat')) + , group: conflict.group + , ownerName: conflict.owner.name + , ownerEmail: conflict.owner.email + }) + }) + }) + $scope.groupsEnv[group.id].showConflicts = true + } + }) + } + + $scope.updateGroupState = function(group) { + CommonService.errorWrapper( + GroupsService.updateGroup + , [group.id, {'state': 'ready'}]) + } + + $scope.updateGroupName = function(group) { + CommonService.errorWrapper( + GroupsService.updateGroup + , [group.id, {'name': $scope.groupsEnv[group.id].tmpName}]) + } + + $scope.$on('user.settings.groups.updated', function(event, message) { + const isChangedSchedule = message.isChangedDates || message.isChangedClass + const doGetDevices = + !CommonService.isOriginGroup(message.group.class) && + (isChangedSchedule || message.devices.length) + const isGroupOwner = $scope.isAdmin() || $scope.currentUser.email === message.group.owner.email + const group = updateGroup( + message.group + , message.timeStamp + , !isGroupOwner) + + if (group) { + if ($scope.isAdmin()) { + if (!CommonService.isOriginGroup(group.class)) { + if (message.devices.length) { + if (!message.isAddedDevice) { + addStandardizableDevicesIfNotBooked(message.devices, message.timeStamp) + } + else { + message.devices.forEach(function(serial) { + deleteStandardizableDevice(serial, message.timeStamp) + }) + } + } + } + else if (message.isChangedClass) { + getAvailableGroupDevices(group) + } + } + if (isChangedSchedule && group.state !== 'pending') { + $scope.initTemporarySchedule(group) + } + if (doGetDevices) { + $scope.groups.forEach(function(group) { + if (group.id !== message.group.id || isChangedSchedule) { + getAvailableGroupDevices(group) + } + }) + } + } + else if (!isGroupOwner && doGetDevices) { // a completer ... soit propriétaire et event obsolete, soit non propriétaire donc non admin + $scope.groups.forEach(function(group) { + getAvailableGroupDevices(group) + }) + } + }) + + $scope.$on('user.settings.groups.created', function(event, message) { + addGroup(message.group, message.timeStamp) + }) + + $scope.$on('user.settings.groups.deleted', function(event, message) { + const group = message.group + + if (deleteGroup(group.id, message.timeStamp)) { + if ($scope.isAdmin() && !CommonService.isOriginGroup(group.class)) { + addStandardizableDevicesIfNotBooked(group.devices, message.timeStamp) + } + } + if (!CommonService.isOriginGroup(group.class) && group.devices.length) { + $scope.groups.forEach(function(group) { + if (!CommonService.isOriginGroup(group.class)) { + getAvailableGroupDevices(group) + } + }) + } + }) + + $scope.$on('user.settings.users.updated', function(event, message) { + function getGroupClass(id) { + if (CommonService.isExisting(groupsById[id])) { + return Promise.resolve($scope.groups[groupsById[id].index].class) + } + else if (cachedGroupsClass[id]) { + return Promise.resolve(cachedGroupsClass[id]) + } + else { + return GroupsService.getGroup(id).then(function(response) { + cachedGroupsClass[id] = response.data.group.class + return cachedGroupsClass[id] + }) + .catch(function(error) { + return false + }) + } + } + + if (($scope.isAdmin() && + CommonService.isExisting($scope.usersByEmail[message.user.email]) || + message.user.email === $scope.currentUser.email + ) && + updateUser(message.user, message.timeStamp) && + message.groups.length) { + + Promise.map(message.groups, function(groupId) { + return getGroupClass(groupId).then(function(_class) { + return !_class || _class === 'bookable' + }) + }) + .then(function(results) { + if (_.without(results, false).length) { + Promise.map($scope.groups, function(group) { + if (group.owner.email === message.user.email && + !CommonService.isOriginGroup(group.class)) { + getAvailableGroupDevices(group) + } + }) + } + }) + } + }) + + $scope.$on('user.settings.users.created', function(event, message) { + addUser(message.user, message.timeStamp) + }) + + $scope.$on('user.settings.users.deleted', function(event, message) { + deleteUser(message.user.email, message.timeStamp) + }) + + $scope.$on('user.settings.devices.deleted', function(event, message) { + if ($scope.isAdmin()) { + deleteOriginDevice(message.device.serial, message.timeStamp) + deleteStandardizableDevice(message.device.serial, message.timeStamp) + } + $scope.groups.forEach(function(group) { + if (!CommonService.isOriginGroup(group.class)) { + deleteAvailableGroupDevice(group.id, message.device.serial, message.timeStamp) + } + }) + }) + + $scope.$on('user.settings.devices.created', function(event, message) { + const device = publishDevice(message.device) + + if ($scope.isAdmin()) { + addOriginDevice(device, message.timeStamp) + addStandardizableDevice(device, message.timeStamp) + } + }) + + $scope.$on('user.settings.devices.updated', function(event, message) { + const device = publishDevice(message.device) + + if ($scope.isAdmin()) { + updateOriginDevice(device, message.timeStamp) + updateStandardizableDeviceIfNotBooked(device, message.timeStamp) + } + $scope.groups.forEach(function(group) { + if (!CommonService.isOriginGroup(group.class)) { + if (device.group.origin !== message.oldOriginGroupId) { + if ($scope.currentUser.groups.subscribed.indexOf(device.group.origin) > -1) { + getAvailableGroupDevices(group, message.timeStamp) + } + else { + deleteAvailableGroupDevice(group.id, device.serial, message.timeStamp) + } + } + else { + updateAvailableGroupDevice(group.id, device, message.timeStamp, true) + } + } + }) + }) + + initScope() +} diff --git a/res/app/settings/groups/groups-spec.js b/res/app/settings/groups/groups-spec.js new file mode 100644 index 00000000..39587018 --- /dev/null +++ b/res/app/settings/groups/groups-spec.js @@ -0,0 +1,21 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +describe('GroupsCtrl', function() { + + beforeEach(angular.mock.module(require('./index').name)) + + var scope, ctrl + + beforeEach(inject(function($rootScope, $controller) { + scope = $rootScope.$new() + ctrl = $controller('GroupsCtrl', {$scope: scope}) + })) + + it('should ...', inject(function() { + expect(1).toEqual(1) + + })) + +}) diff --git a/res/app/settings/groups/groups.css b/res/app/settings/groups/groups.css new file mode 100644 index 00000000..6c9cf631 --- /dev/null +++ b/res/app/settings/groups/groups.css @@ -0,0 +1,107 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +.stf-groups .selectable { + user-select: text; +} + +.stf-pager-groups-total-items { + margin-top: 5px; +} + +.stf-groups .groups-header { + margin-left: 10px; +} + +.stf-groups .group-users-header, .group-devices-header { + margin-bottom: 15px; +} + +.stf-groups .btn-check-name, .btn-group-devices-action, .btn-group-users-action { + margin-top: 5px; +} + +.stf-groups .groups-action { + margin-top: 10px; +} + +.stf-groups .group-schedule-item { + margin: 0px 10px 15px 15px; +} + +.stf-groups td,th { + padding: 0px; + white-space: nowrap; + font-size: small; +} + +.stf-groups .group-list-icon { + margin-right: 10px; +} + +.stf-groups .group-list-label { + font-weight: bold; + margin-right: 10px; +} + +.stf-groups .group-span-label, .group-conflicts { + margin-left: 10px; +} + +.stf-groups .group-span-label-error { + margin-left: 10px; + color: #FF2D55; +} + +.stf-groups .group-span-label-warning { + margin-left: 10px; + color: #FFA101; +} + +.stf-groups .group-span-label-success { + margin-left: 10px; + color: #60c561; +} + +.stf-groups input.ng-invalid { + border-color: red; +} + +.stf-groups .groups-list a.link { + padding: 0px; + border-bottom: none; + color: #167FFC; +} + +.stf-groups .groups-list .group-line { + padding: 10px; + border-bottom: 1px solid #dddddd; +} + +.stf-groups .groups-list .group-line.group-actions { + padding-bottom: 23px; +} + +.stf-groups .groups-list .heading.group-action-body { + margin-top: 22px; +} + +.stf-groups .group-list-details { + display: inline-block; +} + +.stf-groups .group-list-name { + color: #007aff; + font-size: 14px; + font-weight: 300; + margin: 2px 0 6px; +} + +.stf-groups .group-list-id { + font-family: Monaco, Menlo, Consolas, "Courier New", monospace; + font-size: 10px; + margin-bottom: 2px; + color: #999999; + font-weight: 300; +} diff --git a/res/app/settings/groups/groups.pug b/res/app/settings/groups/groups.pug new file mode 100644 index 00000000..cf2b9023 --- /dev/null +++ b/res/app/settings/groups/groups.pug @@ -0,0 +1,193 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.fluid-height.stf-groups(ng-controller='GroupsCtrl') + .heading + i.fa.fa-object-group + span(translate) Group list + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + ng-disabled='!conditionForGroupCreation() || hideGroupCreation' + uib-tooltip="{{'Groups number quota is reached' | translate}}" + tooltip-placement='bottom' + tooltip-enable="!conditionForGroupCreation()" + tooltip-popup-delay='500' + ng-click='createGroup()') + i.fa.fa-plus.fa-fw + + a.pull-right.btn.btn-xs(ng-href='') + i.fa.fa-question-circle.fa-fw( + uib-tooltip='{{"More about Groups" | translate}}' + tooltip-placement='left' + tooltip-popup-delay='500') + + .widget-content.padded + + nothing-to-show( + icon='fa-object-group' + message='{{"No Groups" | translate}}' ng-if='!groups.length') + + div(ng-if='groups.length') + ul.list-group.groups-list + li.list-group-item + .group-line.group-actions + form.form-inline.groups-header + .form-group + stf-pager( + tooltip-label="{{'Group selection' | translate}}" + total-items='filteredGroups.length' + total-items-style='stf-pager-groups-total-items' + items-per-page='scopeGroupsCtrl.groupItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='scopeGroupsCtrl.groupCurrentPage' + items-search='search') + + button.btn.btn-xs.btn-danger.pull-right( + type='button' + ng-disabled="!filteredGroups.length || filteredGroups.length === 1 && filteredGroups[0].privilege === 'root'" + uib-tooltip="{{'Remove the group selection' | translate}}" + tooltip-placement='bottom' + tooltip-popup-delay='500' + ng-click='removeGroups(search, filteredGroups, confirmRemove.value)') + i.fa.fa-trash-o + span(translate) Remove + + button.btn.btn-xs.btn-success.pull-right( + type='button' + uib-tooltip="{{'Enable/Disable confirmation for group removing' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='confirmRemove.value = !confirmRemove.value' + ng-class='{"btn-warning-outline": !confirmRemove.value, "btn-success": confirmRemove.value}') + i.fa.fa-lock(ng-if='confirmRemove.value') + i.fa.fa-unlock(ng-if='!confirmRemove.value') + span(translate) Confirm Remove + + button.btn.btn-xs.btn-primary-outline.pull-right( + ng-if='isAdmin()' + type='button' + uib-tooltip="{{'Write an email to the group owner selection' | translate}}" + ng-disabled='!filteredGroups.length' + ng-click='mailToGroupOwners(filteredGroups)' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Owners + + li.list-group-item(ng-repeat="group in groups \ + | filter:search \ + | orderBy: 'name' \ + | pagedObjectsFilter:scopeGroupsCtrl:'groupCurrentPage':'groupItemsPerPage':'filteredGroups' \ + track by group.id") + .group-line.group-actions + i.fa.fa-object-group.fa-2x.fa-fw.group-list-icon + .group-list-details.selectable + form.form-inline(name='nameForm' ng-if="group.state === 'pending' && showName") + input.form-control.input-sm( + size='35' type='text' placeholder="Name" + ng-model='groupsEnv[group.id].tmpName' + ng-pattern="nameRegex" + uib-tooltip="{{'Regex syntax' | translate}}: {{::nameRegexStr}}" + tooltip-placement='top' + tooltip-popup-delay='500' + tooltip-enable="group.state === 'pending' && nameForm.$invalid" + required) + + button.btn.btn-sm.btn-primary.btn-check-name( + type='button' + ng-click='updateGroupName(group)' + ng-disabled='!conditionForNameSaving(group, nameForm.$invalid)') + i.fa.fa-check + + .group-list-name( + ng-bind-template='{{group.name}}' + ng-if="group.state !== 'pending' || !showName") + + .group-list-id + span(translate) Identifier + span(ng-bind-template="{{::': ' + group.id + ' - '}}") + span(translate) Class + span(ng-bind-template="{{': ' + getClassName(group.class) + ' - '}}") + span(translate) Devices + span(ng-bind-template="{{': ' + group.devices.length + ' - '}}") + span(translate) Users + span(ng-bind-template="{{': ' + group.users.length}}") + span(ng-if='isAdmin()' ng-bind-template="{{::' - '}}") + span(ng-if='isAdmin()' translate) Owner + span(ng-if='isAdmin()' ng-bind-template="{{::': ' + group.owner.name}}") + + button.btn.btn-xs.btn-danger-outline.pull-right( + type='button' + ng-click='removeGroup(group, confirmRemove.value)' + ng-disabled='group.privilege === "root"') + i.fa.fa-trash-o + span(translate) Remove + + button.btn.btn-xs.btn-danger-outline.pull-right( + type='button' + ng-if="group.state === 'pending'" + ng-click='updateGroupState(group)') + i.fa.fa-unlock + span(translate) Get ready + + button.btn.btn-xs.pull-right( + type='button' + ng-show="group.state === 'pending'" + ng-click='initTemporaryName(group); showName = !showName' + ng-class='{"btn-primary-outline": !showName && group.state === "pending",\ + "btn-primary": showName && group.state === "pending"}') + i.fa.fa-tag + span(translate) Name + + button.btn.btn-xs.pull-right( + type='button' + ng-click='initTemporarySchedule(group); showSchedule = !showSchedule' + ng-class='{"btn-primary-outline": !showSchedule && group.state === "pending",\ + "btn-primary": showSchedule && group.state === "pending",\ + "btn-warning-outline": !showSchedule && !group.isActive && group.state !== "pending",\ + "btn-warning": showSchedule && !group.isActive && group.state !== "pending",\ + "btn-success-outline": !showSchedule && group.isActive && group.state !== "pending",\ + "btn-success": showSchedule && group.isActive && group.state !== "pending"}') + i.fa.fa-clock-o + span(translate) Schedule + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + ng-click='initShowDevices(group, !showDevices); showDevices = !showDevices' + ng-class='{"btn-primary-outline": !showDevices, "btn-primary": showDevices}') + i.fa.fa-mobile + span(translate) Devices + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + ng-click='initShowUsers(group); showUsers = !showUsers' + ng-class='{"btn-primary-outline": !showUsers, "btn-primary": showUsers}') + i.fa.fa-user + span(translate) Users + + button.btn.btn-xs.btn-danger.pull-right( + type='button' + ng-if='groupsEnv[group.id].showConflicts' + ng-click='groupsEnv[group.id].showConflicts = !groupsEnv[group.id].showConflicts' + ng-class='{"btn-danger-outline": !groupsEnv[group.id].showConflicts, \ + "btn-danger": groupsEnv[group.id].showConflicts}') + i.fa.fa-ban + span(translate) Conflicts + + ul.list-group.groups-action( + ng-if='groupsEnv[group.id].showConflicts') + div(ng-include="'settings/groups/conflicts/conflicts.pug'") + + ul.list-group.groups-action( + ng-if='showSchedule') + div(ng-include="'settings/groups/schedule/schedule.pug'") + + ul.list-group.groups-action( + ng-if='showDevices') + div(ng-include="'settings/groups/devices/devices.pug'") + + ul.list-group.groups-action( + ng-if='showUsers') + div(ng-include="'settings/groups/users/users.pug'") diff --git a/res/app/settings/groups/index.js b/res/app/settings/groups/index.js new file mode 100644 index 00000000..10620dbf --- /dev/null +++ b/res/app/settings/groups/index.js @@ -0,0 +1,35 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +require('./groups.css') + +module.exports = angular.module('stf.settings.groups', [ + require('stf/users').name, + require('stf/devices').name, + require('stf/user').name, + require('stf/groups').name, + require('stf/settings').name, + require('stf/util/common').name, + require('stf/common-ui').name +]) + .run(['$templateCache', function($templateCache) { + $templateCache.put( + 'settings/groups/groups.pug', require('./groups.pug') + ) + $templateCache.put( + 'settings/groups/schedule/schedule.pug', require('./schedule/schedule.pug') + ) + $templateCache.put( + 'settings/groups/devices/devices.pug', require('./devices/devices.pug') + ) + $templateCache.put( + 'settings/groups/users/users.pug', require('./users/users.pug') + ) + $templateCache.put( + 'settings/groups/conflicts/conflicts.pug', require('./conflicts/conflicts.pug') + ) + }]) + .controller('GroupsCtrl', require('./groups-controller')) + .filter('availableObjectsFilter', require('./filters/available-objects-filter')) + .filter('groupObjectsFilter', require('./filters/group-objects-filter')) diff --git a/res/app/settings/groups/schedule/schedule.pug b/res/app/settings/groups/schedule/schedule.pug new file mode 100644 index 00000000..2810d22c --- /dev/null +++ b/res/app/settings/groups/schedule/schedule.pug @@ -0,0 +1,68 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +li.list-group-item.groups-list + .heading.group-action-body + i.fa.fa-clock-o + span(translate) Schedule + + form.form-inline(name='scheduleForm') + fieldset(ng-disabled="group.state !== 'pending'") + .form-group.group-schedule-item + label.group-list-label(translate) Class + select(ng-model='groupsEnv[group.id].tmpClass' ng-change='watchGroupClass(group)') + option( + ng-if="option.privilege === 'user' ||\ + option.privilege === currentUser.privilege && currentUser.email === group.owner.email" + ng-repeat='option in classOptions' + value='{{option.id}}') {{option.name}} + + .form-group.group-schedule-item(ng-if='conditionForRepetitions(group)') + label.group-list-label(translate) Repetitions + input.form-control.input-sm( + type='range' + min='0' + max='{{getRepetitionsQuotas(group.owner.email)}}' + ng-model='groupsEnv[group.id].tmpRepetitions' + required) + span.group-span-label {{groupsEnv[group.id].tmpRepetitions}} + + .form-group.group-schedule-item + label.group-list-label(translate) Starting Date + input.form-control.input-sm( + size='21' + type='datetime-local' + ng-model='groupsEnv[group.id].tmpStartDate' + placeholder='yyyy-MM-ddTHH:mm:ss:sss' + required) + + .form-group.group-schedule-item + label.group-list-label(translate) Expiration Date + input.form-control.input-sm( + size='21' + type='datetime-local' + ng-model='groupsEnv[group.id].tmpStopDate' + placeholder='yyyy-MM-ddTHH:mm:ss:sss' + required) + + .form-group.group-schedule-item + button.btn.btn-sm.btn-primary( + type='button' + ng-click='updateGroupSchedule(group)' + ng-disabled='!conditionForScheduleSaving(group, scheduleForm.$invalid)') + span(translate) Save + + span.group-span-label-warning( + translate + ng-if="group.state === 'pending' && \ + conditionForScheduleSaving(group, scheduleForm.$invalid) && \ + (groupsEnv[group.id].tmpClass === 'bookable' || \ + groupsEnv[group.id].tmpClass === 'standard')") Saving will also get ready the group! + + span.group-span-label-error( + translate + ng-if="group.state === 'pending' && \ + !conditionForScheduleSaving(group, scheduleForm.$invalid) && \ + groupsEnv[group.id].tmpScheduleTooltip !== 'No change'") {{groupsEnv[group.id].tmpScheduleTooltip}} + diff --git a/res/app/settings/groups/users/users.pug b/res/app/settings/groups/users/users.pug new file mode 100644 index 00000000..9c6aae4d --- /dev/null +++ b/res/app/settings/groups/users/users.pug @@ -0,0 +1,166 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +li.list-group-item.groups-list + .heading.group-action-body + i.fa.fa-user + span(translate) Users + + .row + .panel-group + .panel.panel-default + .panel-heading.text-center + button.btn.btn-xs.btn-primary.btn-group-users-action( + type='button' + ng-click='showGroupUsers = !showGroupUsers' + ng-class='{"btn-primary-outline": showGroupUsers, "btn-primary": !showGroupUsers}') + i.fa.fa-user + span(translate) Group users + + .panel-body(ng-show='!showGroupUsers') + div + .form-inline + .form-group.group-users-header + stf-pager( + tooltip-label="{{'Group user selection' | translate}}" + total-items='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupUsers.length' + total-items-style='stf-pager-groups-total-items' + items-per-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].groupUserItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].groupUserCurrentPage' + items-search='groupUserSearch') + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + uib-tooltip="{{'Write a mail to the group user selection' | translate}}" + ng-disabled='!groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupUsers.length' + ng-click='mailToGroupUsers(\ + filteredGroups[getGroupIndex($parent.$index)],\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupUsers)' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Users + + .widget-container.fluid-height.overflow-auto + table.table.table-hover.dataTable.ng-table + thead + tr + th.header + button.btn.btn-sm.btn-danger.btn-group-users-action( + type='button' + ng-if="groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].hasOwnProperty('filteredGroupUsers')" + ng-disabled="!conditionForGroupUsersRemoving(\ + group, \ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupUsers)" + ng-click='removeGroupUsers(\ + group,\ + groupUserSearch,\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredGroupUsers)') + i.fa.fa-trash-o + th.header.sortable( + ng-class='[column.sort]' + ng-repeat="column in groupUserData.columns" + ng-click='sortBy(groupUserData, column)') + div.strong(ng-bind-template="{{column.name | translate}}") + + tbody + tr.selectable(ng-repeat="user in group.users \ + | groupObjectsFilter:users:usersByEmail \ + | filter:groupUserSearch \ + | orderBy:userColumns[groupUserData.sort.index].property:groupUserData.sort.reverse \ + | pagedObjectsFilter:\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id]:\ + 'groupUserCurrentPage':'groupUserItemsPerPage':'filteredGroupUsers' \ + track by user.email") + td + button.btn.btn-danger-outline.btn-xs( + type='button' + ng-disabled="user.privilege === 'admin' || \ + user.email === filteredGroups[getGroupIndex($parent.$index)].owner.email" + ng-click='removeGroupUser(filteredGroups[getGroupIndex($parent.$index)], user)') + i.fa.fa-trash-o.fa-fw + td {{user.name}} + td + a.link(ng-href="{{'mailto:' + user.email}}" + ng-click='$event.stopPropagation()') {{user.email}} + td {{user.privilege}} + + .panel.panel-default + .panel-heading.text-center + button.btn.btn-xs.btn-primary-outline.btn-group-users-action( + type='button' + ng-click='showAvailableUsers = !showAvailableUsers' + ng-class='{"btn-primary-outline": !showAvailableUsers, "btn-primary": showAvailableUsers}') + i.fa.fa-user + span(translate) Available users + + .panel-body(ng-show='showAvailableUsers') + nothing-to-show( + icon='fa-user' + message='{{"No available users" | translate}}' + ng-if='!groupsEnv[group.id].filteredAvailableUsers.length && users.length === group.users.length') + + div(ng-if='groupsEnv[group.id].filteredAvailableUsers.length || users.length !== group.users.length') + .form-inline + .form-group.group-users-header + stf-pager( + tooltip-label="{{'Available user selection' | translate}}" + total-items='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableUsers.length' + total-items-style='stf-pager-groups-total-items' + items-per-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].availableUserItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].availableUserCurrentPage' + items-search='userSearch') + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + uib-tooltip="{{'Write a mail to the available user selection' | translate}}" + ng-disabled='!groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableUsers.length' + ng-click='mailToAvailableUsers(groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableUsers)' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Users + + + .widget-container.fluid-height.overflow-auto + table.table.table-hover.dataTable.ng-table + thead + tr + th.header + button.btn.btn-sm.btn-primary.btn-group-users-action( + type='button' + ng-if="groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].hasOwnProperty('filteredAvailableUsers')" + ng-disabled='!groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableUsers.length' + ng-click='addGroupUsers(\ + group,\ + userSearch,\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id].filteredAvailableUsers)') + i.fa.fa-cart-plus + th.header.sortable( + ng-class='[column.sort]' + ng-repeat="column in userData.columns" + ng-click='sortBy(userData, column)') + div.strong(ng-bind-template="{{column.name | translate}}") + + tbody + tr.selectable(ng-repeat="user in users \ + | availableObjectsFilter:filteredGroups[getGroupIndex($parent.$index)]:'users':'email' \ + | filter:userSearch \ + | orderBy:userColumns[userData.sort.index].property:userData.sort.reverse \ + | pagedObjectsFilter:\ + groupsEnv[filteredGroups[getGroupIndex($parent.$index)].id]:\ + 'availableUserCurrentPage':'availableUserItemsPerPage':'filteredAvailableUsers' \ + track by user.email") + td + button.btn.btn-primary-outline.btn-xs( + type='button' + ng-click='addGroupUser(filteredGroups[getGroupIndex($parent.$index)], user)') + i.fa.fa-cart-plus.fa-fw + td {{user.name}} + td + a.link(ng-href="{{'mailto:' + user.email}}" + ng-click='$event.stopPropagation()') {{user.email}} + td {{user.privilege}} diff --git a/res/app/settings/index.js b/res/app/settings/index.js index 8fc74525..ec0ddd38 100644 --- a/res/app/settings/index.js +++ b/res/app/settings/index.js @@ -1,6 +1,16 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +require('./settings.css') + module.exports = angular.module('ui-settings', [ require('./general').name, require('./keys').name, + require('./groups').name, + require('./devices').name, + require('./users').name, + require('stf/app-state').name, require('stf/common-ui/nice-tabs').name //require('./notifications').name ]) diff --git a/res/app/settings/settings-controller.js b/res/app/settings/settings-controller.js index d157fc12..35529345 100644 --- a/res/app/settings/settings-controller.js +++ b/res/app/settings/settings-controller.js @@ -1,15 +1,45 @@ -module.exports = function SettingsCtrl($scope, gettext) { +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ - $scope.settingTabs = [ +module.exports = function SettingsCtrl($scope, gettext, AppState) { + + $scope.settingTabs = [] + $scope.settingTabs.push( { title: gettext('General'), icon: 'fa-gears fa-fw', templateUrl: 'settings/general/general.pug' - }, + } + ) + $scope.settingTabs.push( { title: gettext('Keys'), icon: 'fa-key fa-fw', templateUrl: 'settings/keys/keys.pug' } - ] + ) + $scope.settingTabs.push( + { + title: gettext('Groups'), + icon: 'fa-object-group fa-fw', + templateUrl: 'settings/groups/groups.pug' + } + ) + if (AppState.user.privilege === 'admin') { + $scope.settingTabs.push( + { + title: gettext('Devices'), + icon: 'fa-mobile stf-settings-tabs-device-icon fa-fw', + templateUrl: 'settings/devices/devices.pug' + } + ) + $scope.settingTabs.push( + { + title: gettext('Users'), + icon: 'fa-user fa-fw', + templateUrl: 'settings/users/users.pug' + } + ) + } } diff --git a/res/app/settings/settings.css b/res/app/settings/settings.css new file mode 100644 index 00000000..7991ce90 --- /dev/null +++ b/res/app/settings/settings.css @@ -0,0 +1,8 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +.stf-settings-tabs-device-icon { + font-size: 15px; +} + diff --git a/res/app/settings/users/index.js b/res/app/settings/users/index.js new file mode 100644 index 00000000..e8e460ee --- /dev/null +++ b/res/app/settings/users/index.js @@ -0,0 +1,18 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +require('./users.css') + +module.exports = angular.module('stf.settings.users', [ + require('stf/app-state').name, + require('stf/settings').name, + require('stf/util/common').name, + require('stf/users').name +]) + .run(['$templateCache', function($templateCache) { + $templateCache.put( + 'settings/users/users.pug', require('./users.pug') + ) + }]) + .controller('UsersCtrl', require('./users-controller')) diff --git a/res/app/settings/users/users-controller.js b/res/app/settings/users/users-controller.js new file mode 100644 index 00000000..a437dae8 --- /dev/null +++ b/res/app/settings/users/users-controller.js @@ -0,0 +1,229 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +const _ = require('lodash') + +module.exports = function UsersCtrl( + $scope +, UsersService +, AppState +, SettingsService +, ItemsPerPageOptionsService +, GenericModalService +, CommonService +) { + const usersByEmail = {} + const userFields = + 'email,' + + 'name,' + + 'privilege,' + + 'groups.quotas' + + function addUser(user, timeStamp) { + return CommonService.add( + $scope.users + , usersByEmail + , user + , 'email' + , timeStamp) + } + + function updateUser(user, timeStamp) { + return CommonService.update( + $scope.users + , usersByEmail + , user + , 'email' + , timeStamp) + } + + function deleteUser(email, timeStamp) { + return CommonService.delete( + $scope.users + , usersByEmail + , email + , timeStamp) + } + + function initScope() { + UsersService.getOboeUsers(userFields, function(user) { + addUser(user, -1) + }) + .done(function() { + $scope.$digest() + if (CommonService.isExisting(usersByEmail[AppState.user.email])) { + $scope.adminUser = $scope.users[usersByEmail[AppState.user.email].index] + } + }) + } + + SettingsService.bind($scope, { + target: 'removingFilters' + , source: 'UsersRemovingFilters' + , defaultValue: { + groupOwner: 'False' + } + }) + $scope.users = [] + $scope.confirmRemove = {value: true} + $scope.scopeUsersCtrl = $scope + $scope.itemsPerPageOptions = ItemsPerPageOptionsService + SettingsService.bind($scope, { + target: 'userItemsPerPage' + , source: 'userItemsPerPage' + , defaultValue: $scope.itemsPerPageOptions[2] + }) + $scope.tmpEnv = {} + $scope.nameRegex = /^[0-9a-zA-Z-_. ]{1,50}$/ + $scope.nameRegexStr = '/^[0-9a-zA-Z-_. ]{1,50}$/' + $scope.removingFilterOptions = ['True', 'False', 'Any'] + + $scope.mailTo = function(users) { + CommonService.copyToClipboard(users.map(function(user) { + return user.email + }) + .join(SettingsService.get('emailSeparator'))) + .url('mailto:?body=*** Paste the email addresses from the clipboard! ***') + } + + $scope.removeUser = function(email, askConfirmation) { + if (askConfirmation) { + GenericModalService.open({ + message: 'Really delete this user?' + , type: 'Warning' + , size: 'sm' + , cancel: true + }) + .then(function() { + CommonService.errorWrapper( + UsersService.removeUser + , [email, $scope.removingFilters] + ) + }) + } + else { + CommonService.errorWrapper( + UsersService.removeUser + , [email, $scope.removingFilters] + ) + } + } + + $scope.removeUsers = function(search, filteredUsers, askConfirmation) { + function removeUsers() { + CommonService.errorWrapper( + UsersService.removeUsers + , search ? + [$scope.removingFilters, filteredUsers.map(function(user) { return user.email }).join()] : + [$scope.removingFilters] + ) + } + + if (askConfirmation) { + GenericModalService.open({ + message: 'Really delete this selection of users?' + , type: 'Warning' + , size: 'sm' + , cancel: true + }) + .then(function() { + removeUsers() + }) + } + else { + removeUsers() + } + } + + $scope.conditionForDefaultQuotasSaving = function(formInvalidStatus) { + if (formInvalidStatus) { + $scope.tmpEnv.defaultQuotasTooltip = 'Bad syntax' + return false + } + if ($scope.tmpEnv.defaultGroupsNumber + !== $scope.adminUser.groups.quotas.defaultGroupsNumber || + $scope.tmpEnv.defaultGroupsDuration + !== $scope.adminUser.groups.quotas.defaultGroupsDuration || + $scope.tmpEnv.defaultGroupsRepetitions + !== $scope.adminUser.groups.quotas.defaultGroupsRepetitions + ) { + $scope.tmpEnv.defaultQuotasTooltip = '' + return true + } + $scope.tmpEnv.defaultQuotasTooltip = 'No change' + return false + } + + $scope.initTemporaryDefaultQuotas = function() { + $scope.tmpEnv.defaultGroupsNumber = $scope.adminUser.groups.quotas.defaultGroupsNumber + $scope.tmpEnv.defaultGroupsDuration = $scope.adminUser.groups.quotas.defaultGroupsDuration + $scope.tmpEnv.defaultGroupsRepetitions = $scope.adminUser.groups.quotas.defaultGroupsRepetitions + $scope.tmpEnv.defaultQuotasTooltip = 'No change' + } + + $scope.updateDefaultUserGroupsQuotas = function() { + CommonService.errorWrapper(UsersService.updateDefaultUserGroupsQuotas, [ + $scope.tmpEnv.defaultGroupsNumber + , $scope.tmpEnv.defaultGroupsDuration + , $scope.tmpEnv.defaultGroupsRepetitions + ]) + } + + $scope.updateUserGroupsQuotas = function(user) { + CommonService.errorWrapper(UsersService.updateUserGroupsQuotas, [ + user.email + , user.groupsNumber + , user.groupsDuration + , user.groupsRepetitions + ]) + } + + $scope.initTemporaryUser = function() { + $scope.tmpEnv.userName = $scope.tmpEnv.userEmail = '' + $scope.tmpEnv.userTooltip = 'Bad syntax' + } + + $scope.conditionForQuotasSaving = function(user, formInvalidStatus) { + if (formInvalidStatus) { + user.quotasTooltip = 'Bad syntax' + return false + } + if (user.groupsNumber !== user.groups.quotas.allocated.number || + user.groupsDuration !== user.groups.quotas.allocated.duration || + user.groupsRepetitions !== user.groups.quotas.repetitions) { + user.quotasTooltip = '' + return true + } + user.quotasTooltip = 'No change' + return false + } + + $scope.initTemporaryQuotas = function(user) { + user.groupsNumber = user.groups.quotas.allocated.number + user.groupsDuration = user.groups.quotas.allocated.duration + user.groupsRepetitions = user.groups.quotas.repetitions + user.quotasTooltip = 'No change' + } + + $scope.createUser = function() { + CommonService.errorWrapper( + UsersService.createUser + , [$scope.tmpEnv.userName, $scope.tmpEnv.userEmail] + ) + } + + $scope.$on('user.settings.users.created', function(event, message) { + addUser(message.user, message.timeStamp) + }) + + $scope.$on('user.settings.users.deleted', function(event, message) { + deleteUser(message.user.email, message.timeStamp) + }) + + $scope.$on('user.settings.users.updated', function(event, message) { + updateUser(message.user, message.timeStamp) + }) + + initScope() +} diff --git a/res/app/settings/users/users-spec.js b/res/app/settings/users/users-spec.js new file mode 100644 index 00000000..76fd1a54 --- /dev/null +++ b/res/app/settings/users/users-spec.js @@ -0,0 +1,21 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +describe('UsersCtrl', function() { + + beforeEach(angular.mock.module(require('./index').name)) + + var scope, ctrl + + beforeEach(inject(function($rootScope, $controller) { + scope = $rootScope.$new() + ctrl = $controller('UsersCtrl', {$scope: scope}) + })) + + it('should ...', inject(function() { + expect(1).toEqual(1) + + })) + +}) diff --git a/res/app/settings/users/users.css b/res/app/settings/users/users.css new file mode 100644 index 00000000..f5e43d54 --- /dev/null +++ b/res/app/settings/users/users.css @@ -0,0 +1,87 @@ +/** +* Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +.stf-users .selectable { + user-select: text; +} + +.stf-pager-users-total-items { + margin-top: 5px; +} + +.stf-users .user-creation, .user-default-quotas-item, .user-filters-item, .form-group.user-quotas-item { + margin: 0px 10px 15px 15px; +} + +.stf-users .user-save, .user-default-quotas-save, .form-group.user-quotas-save { + margin: 5px 10px 15px 15px; +} + +.stf-users .user-header { + margin-left: 10px; +} + +.stf-users .user-filters-items { + margin-top: 5px; + margin-bottom: 15px; +} + +.stf-users .user-default-quotas-items, .user-quotas-items { + margin: 0px 0px 15px 0px; +} + +.stf-users .user-list-icon { + margin-right: 10px; +} + +.stf-users .user-list-label { + font-weight: bold; + margin-right: 10px; +} + +.stf-users input.ng-invalid { + border-color: red; +} + +.stf-users .user-list .user-list-items { + margin: 10px 0px 0px 0px; +} + +.stf-users .user-list .user-line { + padding: 10px; + border-bottom: 1px solid #dddddd; +} + +.stf-users .user-list .user-line.user-actions { + padding-bottom: 23px; +} + +.stf-users .user-list .heading.user-action-body { + margin-top: 22px; +} + +.stf-users .user-list-details.selectable a { + padding: 0px; + border-bottom: none; + color: #167FFC; +} + +.stf-users .user-list-details { + display: inline-block; +} + +.stf-users .user-list-name { + color: #007aff; + font-size: 14px; + font-weight: 300; + margin: 2px 0 6px; +} + +.stf-users .user-list-id { + font-family: Monaco, Menlo, Consolas, "Courier New", monospace; + font-size: 10px; + margin-bottom: 2px; + color: #999999; + font-weight: 300; +} diff --git a/res/app/settings/users/users.pug b/res/app/settings/users/users.pug new file mode 100644 index 00000000..00d201c4 --- /dev/null +++ b/res/app/settings/users/users.pug @@ -0,0 +1,216 @@ +// + Copyright © 2019 code initially contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + +.widget-container.fluid-height.stf-users(ng-controller='UsersCtrl') + .heading + i.fa.fa-user + span(translate) User list + + button.btn.btn-primary-outline.pull-right.btn-sm( + ng-click='showCreateUser = !showCreateUser; initTemporaryUser()' + ng-class='{ "btn-primary-outline": !showCreateUser, "btn-primary": showCreateUser }') + i.fa.fa-plus.fa-fw + + a.pull-right.btn.btn-sm(ng-href='') + i.fa.fa-question-circle.fa-fw(uib-tooltip='{{"More about Users" | translate}}' tooltip-placement='left') + + .widget-content.padded + + nothing-to-show(icon='fa-user' message='{{"No Users" | translate}}' ng-if='!users.length') + + div(ng-if='users.length') + ul.list-group.user-list + li.list-group-item(ng-if='showCreateUser') + .user-line + .heading + i.fa.fa-user + span(translate) Create new user + + form.form-inline(name='userForm') + .form-group.user-creation + label.user-list-label(translate) Name + input.form-control.input-sm( + name='nameForm' + uib-tooltip="{{'Regex syntax' | translate}}: {{::nameRegexStr}}" + tooltip-placement='top' + tooltip-popup-delay='500' + tooltip-enable='userForm.nameForm.$invalid' + type='text' ng-model='tmpEnv.userName' ng-pattern="nameRegex" required) + + .form-group.user-creation + label.user-list-label(translate) Email + input.form-control.input-sm(size='35' type='email' ng-model='tmpEnv.userEmail' required) + + .form-group.user-save + button.btn.btn-sm.btn-primary( + type='button' + ng-click='createUser()' + ng-disabled='userForm.$invalid') + span(translate) Save + + li.list-group-item + .user-line.user-actions + form.form-inline.user-header + .form-group + stf-pager( + tooltip-label="{{'User selection' | translate}}" + total-items='filteredUsers.length' + total-items-style='stf-pager-users-total-items' + items-per-page='scopeUsersCtrl.userItemsPerPage' + items-per-page-options='itemsPerPageOptions' + current-page='scopeUsersCtrl.userCurrentPage' + items-search='search') + + button.btn.btn-xs.btn-danger.pull-right( + type='button' + uib-tooltip="{{'Remove the user selection' | translate}}" + tooltip-placement='bottom' + tooltip-popup-delay='500' + ng-disabled="!filteredUsers.length || filteredUsers.length === 1 && filteredUsers[0].privilege === 'admin'" + ng-click='removeUsers(search, filteredUsers, confirmRemove.value)') + i.fa.fa-trash-o + span(translate) Remove + + button.btn.btn-xs.btn-success.pull-right( + type='button' + uib-tooltip="{{'Enable/Disable confirmation for user removing' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='confirmRemove.value = !confirmRemove.value' + ng-class='{"btn-warning-outline": !confirmRemove.value, "btn-success": confirmRemove.value}') + i.fa.fa-lock(ng-if='confirmRemove.value') + i.fa.fa-unlock(ng-if='!confirmRemove.value') + span(translate) Confirm Remove + + button.btn.btn-xs.btn-danger-outline.pull-right( + type='button' + uib-tooltip="{{'Set filters for user removing' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='showFilters = !showFilters' + ng-class='{"btn-danger-outline": !showFilters, "btn-danger": showFilters}') + i.fa.fa-trash-o + span(translate) Filters + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + uib-tooltip="{{'Set groups quotas for new users' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='showDefaultGroupsQuotas = !showDefaultGroupsQuotas; initTemporaryDefaultQuotas()' + ng-class='{"btn-primary-outline": !showDefaultGroupsQuotas, "btn-primary": showDefaultGroupsQuotas}') + i.fa.fa-object-group + span(translate) Default Groups Quotas + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + uib-tooltip="{{'Write an email to the user selection' | translate}}" + ng-disabled='!filteredUsers.length' + ng-click='mailTo(filteredUsers)' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Users + + li.list-group-item(ng-if='showFilters') + .user-line + .heading + i.fa.fa-trash-o + span(translate) Removing filters + + form.form-inline.user-filters-items + .form-group.user-filters-item + label.user-list-label( + translate + uib-tooltip="{{'Filter on user group ownership' | translate}}" + tooltip-placement='top' + tooltip-popup-delay='500') Group Owner + select(ng-model='removingFilters.groupOwner' ng-options='option for option in removingFilterOptions') + + li.list-group-item(ng-if='showDefaultGroupsQuotas') + .user-line + .heading + i.fa.fa-object-group + span(translate) Default groups quotas + + form.form-inline.user-default-quotas-items(name='dafaultQuotasForm') + .form-group.user-default-quotas-item + label.user-list-label(translate) Number of groups + input.form-control.input-sm(type='number' min='0' ng-model='tmpEnv.defaultGroupsNumber' required) + + .form-group.user-default-quotas-item + label.user-list-label Total duration of groups (ms) + input.form-control.input-sm(type='number' min='0' ng-model='tmpEnv.defaultGroupsDuration' required) + + .form-group.user-default-quotas-item + label.user-list-label(translate) Number of repetitions per group + input.form-control.input-sm(type='number' min='0' ng-model='tmpEnv.defaultGroupsRepetitions' required) + + .form-group.user-default-quotas-save + button.btn.btn-sm.btn-primary( + uib-tooltip='{{tmpEnv.defaultQuotasTooltip | translate}}' + tooltip-enable='tmpEnv.defaultQuotasTooltip' + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='updateDefaultUserGroupsQuotas()' + ng-disabled='!conditionForDefaultQuotasSaving(defaultQuotasForm.$invalid)') + span(translate) Save + + li.list-group-item(ng-repeat="user in users \ + | filter:search \ + | orderBy: 'name' \ + | pagedObjectsFilter:scopeUsersCtrl:'userCurrentPage':'userItemsPerPage':'filteredUsers' \ + track by user.email") + .user-line.user-actions + i.fa.fa-user.fa-2x.fa-fw.user-list-icon + .user-list-details.selectable + a.user-list-name(ng-href="{{::'mailto:' + user.email}}") {{::user.name}} + .user-list-id + span(translate) Email + span(ng-bind-template="{{::': ' + user.email + ' - '}}") + span(translate) Privilege + span(ng-bind-template="{{::': ' + user.privilege}}") + + button.btn.btn-xs.btn-danger-outline.pull-right( + type='button' + ng-click='removeUser(user.email, confirmRemove.value)' + ng-disabled='user.privilege === "admin"') + i.fa.fa-trash-o + span(translate) Remove + + button.btn.btn-xs.btn-primary-outline.pull-right( + type='button' + ng-click='showGroupsQuotas = !showGroupsQuotas; initTemporaryQuotas(user)' + ng-class='{"btn-primary-outline": !showGroupsQuotas, "btn-primary": showGroupsQuotas}') + i.fa.fa-object-group + span(translate) Groups Quotas + + ul.list-group.user-list.user-list-items(ng-if='showGroupsQuotas') + li.list-group-item + .heading.user-action-body + i.fa.fa-object-group + span(translate) Groups Quotas + + form.form-inline(name='quotasForm') + .form-group.user-quotas-item + label.user-list-label(translate) Number of groups + input.form-control.input-sm(type='number' min='0' ng-max-length='5' ng-model='user.groupsNumber' required) + + .form-group.user-quotas-item + label.user-list-label(translate) Total duration of groups (ms) + input.form-control.input-sm(type='number' min='0' ng-model='user.groupsDuration' required) + + .form-group.user-quotas-item + label.user-list-label(translate) Number of repetitions per group + input.form-control.input-sm(type='number' min='0' ng-model='user.groupsRepetitions' required) + + .form-group.user-quotas-save + button.btn.btn-sm.btn-primary( + uib-tooltip='{{user.quotasTooltip | translate}}' + tooltip-enable='user.quotasTooltip' + tooltip-placement='top' + tooltip-popup-delay='500' + ng-click='updateUserGroupsQuotas(user)' + ng-disabled='!conditionForQuotasSaving(user, quotasForm.$invalid)') + span(translate) Save diff --git a/res/auth/ldap/scripts/signin/index.js b/res/auth/ldap/scripts/signin/index.js index 0e6d9fde..4525ac97 100644 --- a/res/auth/ldap/scripts/signin/index.js +++ b/res/auth/ldap/scripts/signin/index.js @@ -1,6 +1,13 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + require('./signin.css') -module.exports = angular.module('stf.signin', []) +module.exports = angular.module('stf.signin', [ + require('stf/util/common').name, + require('stf/common-ui').name +]) .config(function($routeProvider) { $routeProvider .when('/auth/ldap/', { diff --git a/res/auth/ldap/scripts/signin/signin-controller.js b/res/auth/ldap/scripts/signin/signin-controller.js index 25aee8d4..dd44c247 100644 --- a/res/auth/ldap/scripts/signin/signin-controller.js +++ b/res/auth/ldap/scripts/signin/signin-controller.js @@ -1,4 +1,8 @@ -module.exports = function SignInCtrl($scope, $http) { +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function SignInCtrl($scope, $http, CommonService) { $scope.error = null @@ -33,4 +37,12 @@ module.exports = function SignInCtrl($scope, $http) { } }) } + + $scope.mailToSupport = function() { + CommonService.url('mailto:' + $scope.contactEmail) + } + + $http.get('/auth/contact').then(function(response) { + $scope.contactEmail = response.data.contact.email + }) } diff --git a/res/auth/ldap/scripts/signin/signin.pug b/res/auth/ldap/scripts/signin/signin.pug index b73d4d44..125f8768 100644 --- a/res/auth/ldap/scripts/signin/signin.pug +++ b/res/auth/ldap/scripts/signin/signin.pug @@ -1,3 +1,7 @@ +// + Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + .login2(ng-controller='SignInCtrl') .login-wrapper a(href='./') @@ -28,3 +32,15 @@ span(translate) Please enter your password input.btn.btn-lg.btn-primary.btn-block(type='submit', value='Log In') + + button.btn.btn-sm.btn-default-outline( + type='button' + uib-tooltip="{{'Write a mail to the support team' | translate}}" + ng-disabled='!contactEmail' + ng-click='mailToSupport()' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Support + + diff --git a/res/auth/mock/scripts/signin/index.js b/res/auth/mock/scripts/signin/index.js index 2f5afe3c..6becbb50 100644 --- a/res/auth/mock/scripts/signin/index.js +++ b/res/auth/mock/scripts/signin/index.js @@ -1,6 +1,13 @@ +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + require('./signin.css') -module.exports = angular.module('stf.signin', []) +module.exports = angular.module('stf.signin', [ + require('stf/util/common').name, + require('stf/common-ui').name +]) .config(function($routeProvider) { $routeProvider .when('/auth/mock/', { diff --git a/res/auth/mock/scripts/signin/signin-controller.js b/res/auth/mock/scripts/signin/signin-controller.js index 70ce9011..410c4d35 100644 --- a/res/auth/mock/scripts/signin/signin-controller.js +++ b/res/auth/mock/scripts/signin/signin-controller.js @@ -1,4 +1,8 @@ -module.exports = function SignInCtrl($scope, $http) { +/** +* Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +module.exports = function SignInCtrl($scope, $http, CommonService) { $scope.error = null @@ -33,4 +37,12 @@ module.exports = function SignInCtrl($scope, $http) { } }) } + + $scope.mailToSupport = function() { + CommonService.url('mailto:' + $scope.contactEmail) + } + + $http.get('/auth/contact').then(function(response) { + $scope.contactEmail = response.data.contact.email + }) } diff --git a/res/auth/mock/scripts/signin/signin.pug b/res/auth/mock/scripts/signin/signin.pug index 1baa352a..0e5076f9 100644 --- a/res/auth/mock/scripts/signin/signin.pug +++ b/res/auth/mock/scripts/signin/signin.pug @@ -1,3 +1,7 @@ +// + Copyright © 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +// + .login2(ng-controller='SignInCtrl') .login-wrapper a(href='./') @@ -29,3 +33,14 @@ span(ng-show='signin.email.$error.required', translate) Please enter your email input.btn.btn-lg.btn-primary.btn-block(type='submit', value='Log In') + + button.btn.btn-sm.btn-default-outline( + type='button' + uib-tooltip="{{'Write a mail to the support team' | translate}}" + ng-disabled='!contactEmail' + ng-click='mailToSupport()' + tooltip-placement='top' + tooltip-popup-delay='500') + i.fa.fa-envelope-o + span(translate) Contact Support +