Added web interface for organizations.

It looks like crap, but it allows you to edit the details and the members.
This commit is contained in:
Sybren A. Stüvel 2017-08-23 15:38:56 +02:00
parent 64eab850c5
commit e9cb235640
6 changed files with 611 additions and 1 deletions

View File

@ -313,6 +313,20 @@ class OrgManager:
'$pull': {'unknown_members': member_email}
})
def org_members(self, member_sting_ids: typing.Iterable[str]) -> typing.List[dict]:
"""Returns the user documents of the organization members.
This is a workaround to provide membership information for
organizations without giving 'mortal' users access to /api/users.
"""
from pillar.api.utils import str2id
member_ids = [str2id(uid) for uid in member_sting_ids]
users_coll = current_app.db('users')
users = users_coll.find({'_id': {'$in': member_ids}},
projection={'_id': 1, 'full_name': 1, 'email': 1})
return list(users)
def setup_app(app):
from . import patch, hooks

View File

@ -1,5 +1,5 @@
def setup_app(app):
from . import main, users, projects, nodes, notifications, redirects, subquery
from . import main, users, projects, nodes, notifications, organizations, redirects, subquery
main.setup_app(app, url_prefix=None)
users.setup_app(app, url_prefix=None)
redirects.setup_app(app, url_prefix='/r')
@ -7,3 +7,4 @@ def setup_app(app):
nodes.setup_app(app, url_prefix='/nodes')
notifications.setup_app(app, url_prefix='/notifications')
subquery.setup_app(app)
organizations.setup_app(app, url_prefix='/orgs')

View File

@ -0,0 +1,5 @@
from .routes import blueprint
def setup_app(app, url_prefix=None):
app.register_blueprint(blueprint, url_prefix=url_prefix)

View File

@ -0,0 +1,83 @@
import logging
import attr
from flask import Blueprint, render_template, request, jsonify
import flask_wtf.csrf
import werkzeug.exceptions as wz_exceptions
from pillarsdk import User
import pillar.flask_extra
from pillar import current_app
from pillar.api.utils import authorization, str2id, gravatar
from pillar.web.system_util import pillar_api
from pillar.api.utils.authentication import current_user
from pillarsdk import Organization
log = logging.getLogger(__name__)
blueprint = Blueprint('pillar.web.organizations', __name__, url_prefix='/organizations')
@blueprint.route('/', endpoint='index')
def index(organization_id: str = None):
api = pillar_api()
organizations = Organization.all(api=api)
if not organization_id and organizations['_items']:
organization_id = organizations['_items'][0]._id
can_create_organization = current_user().has_cap('create-organization')
return render_template('organizations/index.html',
can_create_organization=can_create_organization,
organizations=organizations,
open_organization_id=organization_id)
@blueprint.route('/<organization_id>')
@pillar.flask_extra.vary_xhr()
def view_embed(organization_id: str):
if not request.is_xhr:
return index(organization_id)
api = pillar_api()
organization: Organization = Organization.find(organization_id, api=api)
om = current_app.org_manager
organization_oid = str2id(organization_id)
members = om.org_members(organization.members)
for member in members:
member['avatar'] = gravatar(member.get('email'))
member['_id'] = str(member['_id'])
can_edit = om.user_is_admin(organization_oid)
csrf = flask_wtf.csrf.generate_csrf()
return render_template('organizations/view_embed.html',
organization=organization,
members=members,
can_edit=can_edit,
seats_used=len(members) + len(organization.unknown_members),
csrf=csrf)
@blueprint.route('/create-new', methods=['POST'])
@authorization.require_login(require_cap='create-organization')
def create_new():
"""Creates a new Organization, owned by the currently logged-in user."""
user_id = current_user().user_id
log.info('Creating new organization for user %s', user_id)
name = request.form['name']
seat_count = int(request.form['seat_count'], 10)
org_doc = current_app.org_manager.create_new_org(name, user_id, seat_count)
return jsonify({'_id': org_doc['_id']}), 201

View File

@ -0,0 +1,182 @@
| {% extends 'layout.html' %}
| {% block bodyattrs %}{{ super() }} data-context='organizations'{% endblock %}
| {% block page_title %}Organizations{% endblock %}
| {% block body %}
#col_main.organization-index
#col_main-content
.col_header.item-list-header
i.pi-cloud
| Your organizations
.item-list.col-scrollable
.table
.table-head
.table-row
.table-cell.item-name
span.collapser(title="Collapse name column") Name
.table-cell.item-priority
span.collapser(title="Collapse priority column") Members
.table-cell.item-priority
span.collapser(title="Collapse priority column") Unknown Members
.table-body
| {% for organization in organizations['_items'] %}
| {% set link_url = url_for('pillar.web.organizations.view_embed', organization_id=organization._id) %}
.table-row(id="organization-{{ organization._id }}")
.table-cell.item-name
a(data-organization-id="{{ organization._id }}",
href="{{ link_url }}",
class="organization-link")
span(class="organization-name-{{ organization._id }}") {{ organization.name }}
.table-cell.item-members
a(data-organization-id="{{ organization._id }}",
href="{{ link_url }}",
class="organization-link")
span(class="organization-projects-{{ organization._id }}") {{ organization.members|hide_none|count }}
.table-cell.item-unknown-members
a(data-organization-id="{{ organization._id }}",
href="{{ link_url }}",
class="organization-link")
span(class="organization-projects-{{ organization._id }}") {{ organization.unknown_members|hide_none|count }}
| {% endfor %}
#item-action-panel
| {% if can_create_organization %}
button.btn(onclick='createNewOrganization(this)') Create new organization (max {{max_organizations}})
#create_organization_result_panel
| {% endif %}
#col_right
.col_header
span.header_text
#item-details.col-scrollable
.item-details-empty
| Select an organization
| {% endblock %}
| {% block footer_scripts %}
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/algoliasearch-3.19.0.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.autocomplete-0.22.0.min.js') }}", async=true)
script.
/* Returns a more-or-less reasonable message given an error response object. */
function xhrErrorResponseMessage(err) {
if (typeof err.responseJSON == 'undefined')
return err.statusText;
if (typeof err.responseJSON._error != 'undefined' && typeof err.responseJSON._error.message != 'undefined')
return err.responseJSON._error.message;
if (typeof err.responseJSON._message != 'undefined')
return err.responseJSON._message
return err.statusText;
}
/**
* Open an organization in the #item-details div.
*/
function item_open(item_id, pushState)
{
if (item_id === undefined ) {
throw new ReferenceError("item_open(" + item_id + ") called.");
}
// Style elements starting with item_type and dash, e.g. "#job-uuid"
var clean_classes = 'active processing';
var current_item = $('#organization-' + item_id);
$('[id^="organization-"]').removeClass(clean_classes);
current_item
.removeClass(clean_classes)
.addClass('processing');
var item_url = '/orgs/' + item_id;
statusBarSet('default', 'Loading organization…');
$.get(item_url, function(item_data) {
statusBarClear();
$('#item-details').html(item_data);
$('#col_right .col_header span.header_text').text('Organization details');
current_item
.removeClass(clean_classes)
.addClass('active');
}).fail(function(xhr) {
if (console) {
console.log('Error fetching organization', item_id, 'from', item_url);
console.log('XHR:', xhr);
}
current_item.removeClass(clean_classes);
statusBarSet('error', 'Failed to open organization', 'pi-warning');
if (xhr.status) {
$('#item-details').html(xhr.responseText);
} else {
$('#item-details').html('<p class="text-danger">Opening ' + item_type + ' failed. There possibly was ' +
'an error connecting to the server. Please check your network connection and ' +
'try again.</p>');
}
});
// Determine whether we should push the new state or not.
pushState = (typeof pushState !== 'undefined') ? pushState : true;
if (!pushState) return;
// Push the correct URL onto the history.
var push_state = {itemId: item_id};
window.history.pushState(
push_state,
'Organization: ' + item_id,
item_url
);
}
{% if open_organization_id %}
$(function() { item_open('{{ open_organization_id }}', false); });
{% endif %}
{% if can_create_organization %}
function createNewOrganization(button) {
$(button)
.attr('disabled', 'disabled')
.fadeTo(200, 0.1);
$('#create_organization_result_panel').html('');
// TODO: create a form to get the initial info from the user.
$.post(
'{{ url_for('pillar.organizations.create_new') }}',
{
name: 'New Organization',
seat_count: 1,
}
)
.done(function() {
var $p = $('<p>').text('organization created, reloading list.')
$('#create_organization_result_panel').html($p);
window.location.reload();
})
.fail(function(err) {
var msg = xhrErrorResponseMessage(err);
$('#create_organization_result_panel').html('Error creating organization: ' + msg);
$(button)
.fadeTo(1000, 1.0)
.queue(function() {
$(this)
.removeAttr('disabled')
.dequeue()
;
})
})
;
}
{% endif %}
| {% endblock %}

View File

@ -0,0 +1,325 @@
.flamenco-box.organization
form#item_form(onsubmit="return editOrganization()")
| {% if can_edit %}
.input-group
input.item-name.input-transparent(
name="name",
type="text",
placeholder="Organization's name",
value="{{ organization.name | hide_none }}")
.input-group
textarea.item-description.input-transparent(
name="description",
type="text",
rows=1,
placeholder="Organization's description") {{ organization.description | hide_none }}
.input-group
input.item-website.input-transparent(
name="website",
type="text",
placeholder="Organization's website",
value="{{ organization.website | hide_none }}")
.input-group
input.item-location.input-transparent(
name="location",
type="text",
placeholder="Organization's location",
value="{{ organization.location | hide_none }}")
.input-group
button#item-save.btn.btn-default.btn-block(type='submit')
i.pi-check
| Save Organization
| {% else %}
p.item-name {{ organization.name | hide_none }}
| {% if organization.description %}
p.item-description {{ organization.description | hide_none }}
p.item-website {{ organization.website | hide_none }}
p.item-location {{ organization.location | hide_none }}
| {% endif %}
| {% endif %}
h4 Properties
.table.item-properties
.table-body
.table-row.properties-last-updated
.table-cell Last Updated
.table-cell(title='Unable to edit, set by Organization')
| {{ organization._updated | hide_none | pretty_date_time }}
.table-row.properties-seat-count
.table-cell Seat Count
.table-cell(title='Unable to edit, determined by subscription')
| {{ organization.seat_count }} ({{ seats_used }} used)
.table-row.properties-org-roles
.table-cell User roles
.table-cell(title='Unable to edit, determined by subscription')
| {{ organization.org_roles | sort | join(', ') }}
.flamenco-box.manager
h4 Organization members
| {% if can_edit %}
.row
.sharing-users-search
.form-group
input#user-select.form-control(
name='contacts',
type='text',
placeholder='Add member by name')
| {% endif %}
.row
ul.sharing-users-list
| {% for member in members %}
li.sharing-users-item(
data-user-id="{{ member['_id'] }}",
class="{% if current_user.objectid == member['_id'] %}self{% endif %}")
.sharing-users-avatar
img(src="{{ member['avatar'] }}")
.sharing-users-details
span.sharing-users-name
| {{ member['full_name'] }}
| {% if current_user.objectid == member['_id'] %}
small (You)
| {% endif %}
| {% if organization['admin_uid'] == member['_id'] %}
small (admin)
| {% endif %}
span.sharing-users-extra {{ member['username'] }}
.sharing-users-action
| {% if can_edit %}
| {% if current_user.objectid == member['_id'] %}
button.user-remove(title="Leave as member of this organization")
i.pi-trash
| {% else %}
button.user-remove(title="Remove this user from this organization")
i.pi-trash
| {% endif %}
| {% endif %}
| {% endfor %}
.row
h5 Users without Blender Cloud account
ul.sharing-users-list.unknown-members
| {% for email in organization.unknown_members %}
li.sharing-users-item.unknown-member(data-user-email='{{ email }}')
.sharing-users-avatar
img(src="{{ email | gravatar }}")
.sharing-users-details
span.sharing-users-email {{ email }}
.sharing-users-action
| {% if can_edit %}
button.user-remove(title="Remove this user from this organization")
i.pi-trash
| {% endif %}
| {% endfor %}
| {% if can_edit %}
h5 Batch-add members by email address
form#batch_add_form(onsubmit="return batchAddUsers()")
.input-group
textarea.item-description.input-transparent(
name="emails",
type="text",
rows=1,
placeholder="Email addresses, separated by space/enter")
.input-group
button.btn.btn-default.btn-block(type='submit')
i.pi-check
| Add members
| {% endif %}
.action-result-panel
#item-view-feed
| {% if config.DEBUG %}
.debug-info
a.debug-info-toggle(role='button',
data-toggle='collapse',
href='#debug-content-organization',
aria-expanded='false',
aria-controls='debug-content-organization')
i.pi-info
| Debug Info
#debug-content-organization.collapse
pre.
{{ organization.to_dict() | pprint }}
| {% endif %}
| {% block footer_scripts %}
| {% if can_edit %}
script.
function patchOrganization(patch) {
if (typeof patch == 'undefined') {
throw 'patchOrganization(undefined) called';
}
if (console) console.log('patchOrganization', patch);
var promise = $.ajax({
url: '/api/organizations/{{ organization._id }}',
method: 'PATCH',
contentType: 'application/json',
data: JSON.stringify(patch),
})
.fail(function(err) {
if (console) console.log('Error patching: ', err);
})
;
return promise;
}
$(document).ready(function() {
var APPLICATION_ID = '{{config.ALGOLIA_USER}}'
var SEARCH_ONLY_API_KEY = '{{config.ALGOLIA_PUBLIC_KEY}}';
var INDEX_NAME = '{{config.ALGOLIA_INDEX_USERS}}';
var client = algoliasearch(APPLICATION_ID, SEARCH_ONLY_API_KEY);
var index = client.initIndex(INDEX_NAME);
$('#user-select').autocomplete({hint: false}, [
{
source: function (q, cb) {
index.search(q, {hitsPerPage: 5}, function (error, content) {
if (error) {
cb([]);
return;
}
cb(content.hits, content);
});
},
displayKey: 'full_name',
minLength: 2,
limit: 10,
templates: {
suggestion: function (hit) {
var suggestion = hit.full_name + ' (' + hit.username + ')';
var $p = $('<p>').text(suggestion);
return $p.html();
}
}
}
]).on('autocomplete:selected', function (event, hit, dataset) {
var $existing = $('li.sharing-users-item[data-user-id="' + hit.objectID + '"]');
if ($existing.length) {
$existing
.addClass('active')
.delay(1000)
.queue(function() {
console.log('no');
$existing.removeClass('active');
$existing.dequeue();
});
toastr.info('User is already member of this organization');
}
else {
addUser('{{ organization["_id"] }}', hit.objectID);
}
});
function addUser(organizationId, userId){
if (!userId || userId.length == 0) {
toastr.error('Please select a user from the list');
return;
}
patchOrganization({
op: 'assign-user',
user_id: userId
})
.done(function (data) {
setTimeout(function(){ $('.sharing-users-item').removeClass('added');}, 350);
statusBarSet('success', 'Member added to this organization!', 'pi-grin');
// TODO fsiddi: avoid the reloading of the entire page?
window.location.reload();
})
.fail(function (err) {
var msg = xhrErrorResponseMessage(err);
toastr.error('Could not add member: ' + msg);
});
};
});
| {% endif %}
script.
$(document).ready(function() {
$('body').off('click', '.user-remove'); // remove previous handlers.
$('body').on('click', '.user-remove', function(e) {
var user_id = $(this).closest('*[data-user-id]').data('user-id');
var user_email = $(this).closest('*[data-user-email]').data('user-email');
removeUser(user_id, user_email);
});
function removeUser(user_id, email) {
if (typeof user_id == 'undefined' && typeof email == 'undefined') {
throw "removeUser(undefined, undefined) called";
}
var organization_id = '{{ organization._id }}';
var patch = {op: 'remove-user'};
if (typeof user_id !== 'undefined') {
patch.user_id = user_id;
}
if (typeof email !== 'undefined') {
patch.email = String(email);
}
patchOrganization(patch)
.done(function() {
$("ul.sharing-users-list").find("[data-user-id='" + user_id + "']").remove();
item_open('{{ organization._id }}', false);
toastr.success('User removed from this organization');
}).fail(function (data) {
var msg = xhrErrorResponseMessage(data);
toastr.error('Error removing user: ' + msg);
});
}
});
function editOrganization() {
var $form = $('#item_form');
var new_name = $form.find('*[name="name"]').val();
patchOrganization({
op: 'edit-from-web',
name: new_name,
description: $form.find('*[name="description"]').val(),
website: $form.find('*[name="website"]').val(),
location: $form.find('*[name="location"]').val(),
})
.done(function() {
$('span.organization-name-{{ organization._id }}').text(new_name);
})
.fail(function(err) {
var msg = xhrErrorResponseMessage(err);
toastr.error('Error editing organization: ' + msg);
})
;
return false;
}
function batchAddUsers() {
var $form = $('#batch_add_form');
var emails = $form.find('*[name="emails"]').val().split(/\s/);
console.log(emails);
patchOrganization({
op: 'assign-users',
emails: emails,
})
.done(function() {
item_open('{{ organization._id }}', false);
})
.fail(function(err) {
var msg = xhrErrorResponseMessage(err);
toastr.error('Error adding members: ' + msg);
})
;
return false;
}
| {% endblock %}