From e9cb2356404a1b73f26cddcdf3e0cf9553b5afe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Wed, 23 Aug 2017 15:38:56 +0200 Subject: [PATCH] Added web interface for organizations. It looks like crap, but it allows you to edit the details and the members. --- pillar/api/organizations/__init__.py | 14 + pillar/web/__init__.py | 3 +- pillar/web/organizations/__init__.py | 5 + pillar/web/organizations/routes.py | 83 +++++ src/templates/organizations/index.jade | 182 +++++++++++ src/templates/organizations/view_embed.jade | 325 ++++++++++++++++++++ 6 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 pillar/web/organizations/__init__.py create mode 100644 pillar/web/organizations/routes.py create mode 100644 src/templates/organizations/index.jade create mode 100644 src/templates/organizations/view_embed.jade diff --git a/pillar/api/organizations/__init__.py b/pillar/api/organizations/__init__.py index a95abc36..06b01164 100644 --- a/pillar/api/organizations/__init__.py +++ b/pillar/api/organizations/__init__.py @@ -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 diff --git a/pillar/web/__init__.py b/pillar/web/__init__.py index 5e959f78..bb609537 100644 --- a/pillar/web/__init__.py +++ b/pillar/web/__init__.py @@ -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') diff --git a/pillar/web/organizations/__init__.py b/pillar/web/organizations/__init__.py new file mode 100644 index 00000000..9d09c695 --- /dev/null +++ b/pillar/web/organizations/__init__.py @@ -0,0 +1,5 @@ +from .routes import blueprint + + +def setup_app(app, url_prefix=None): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/web/organizations/routes.py b/pillar/web/organizations/routes.py new file mode 100644 index 00000000..9a26f248 --- /dev/null +++ b/pillar/web/organizations/routes.py @@ -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('/') +@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 diff --git a/src/templates/organizations/index.jade b/src/templates/organizations/index.jade new file mode 100644 index 00000000..2cd03c57 --- /dev/null +++ b/src/templates/organizations/index.jade @@ -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('

Opening ' + item_type + ' failed. There possibly was ' + + 'an error connecting to the server. Please check your network connection and ' + + 'try again.

'); + } + }); + + // 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 = $('

').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 %} diff --git a/src/templates/organizations/view_embed.jade b/src/templates/organizations/view_embed.jade new file mode 100644 index 00000000..8a2a7915 --- /dev/null +++ b/src/templates/organizations/view_embed.jade @@ -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 = $('

').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 %}