diff --git a/app/api/admin.py b/app/api/admin.py index 88c9c0d4..329e00ef 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -10,7 +10,7 @@ class UserSerializer(serializers.ModelSerializer): model = User fields = '__all__' -class UserViewSet(viewsets.ModelViewSet): +class AdminUserViewSet(viewsets.ModelViewSet): serializer_class = UserSerializer permission_classes = [IsAdminUser] @@ -34,7 +34,7 @@ class GroupSerializer(serializers.ModelSerializer): model = Group fields = '__all__' -class GroupViewSet(viewsets.ModelViewSet): +class AdminGroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [IsAdminUser] diff --git a/app/api/projects.py b/app/api/projects.py index 5aaf4cb2..48c25623 100644 --- a/app/api/projects.py +++ b/app/api/projects.py @@ -1,14 +1,19 @@ -from guardian.shortcuts import get_perms +from guardian.shortcuts import get_perms, get_users_with_perms, assign_perm, remove_perm from rest_framework import serializers, viewsets from rest_framework.decorators import detail_route from rest_framework.response import Response from rest_framework import status +from django.db import transaction +from django.contrib.auth.models import User from app import models from .tasks import TaskIDsSerializer from .common import get_and_check_project from django.utils.translation import gettext as _ +def normalized_perm_names(perms): + return list(map(lambda p: p.replace("_project", ""),perms)) + class ProjectSerializer(serializers.ModelSerializer): tasks = TaskIDsSerializer(many=True, read_only=True) owner = serializers.HiddenField( @@ -19,7 +24,7 @@ class ProjectSerializer(serializers.ModelSerializer): def get_permissions(self, obj): if 'request' in self.context: - return list(map(lambda p: p.replace("_project", ""), get_perms(self.context['request'].user, obj))) + return normalized_perm_names(get_perms(self.context['request'].user, obj)) else: # Cannot list permissions, no user is associated with request (happens when serializing ui test mocks) return [] @@ -60,3 +65,74 @@ class ProjectViewSet(viewsets.ModelViewSet): return Response({'success': True, 'project': ProjectSerializer(new_project).data}, status=status.HTTP_200_OK) else: return Response({'error': _("Cannot duplicate project")}, status=status.HTTP_200_OK) + + @detail_route(methods=['get']) + def permissions(self, request, pk=None): + project = get_and_check_project(request, pk, ('change_project', )) + + result = [] + + perms = get_users_with_perms(project, attach_perms=True, with_group_users=False) + for user in perms: + result.append({'username': user.username, + 'owner': project.owner == user, + 'permissions': normalized_perm_names(perms[user])}) + + result.sort(key=lambda r: r['owner'], reverse=True) + return Response(result, status=status.HTTP_200_OK) + + @detail_route(methods=['post']) + def edit(self, request, pk=None): + project = get_and_check_project(request, pk, ('change_project', )) + + try: + with transaction.atomic(): + project.name = request.data.get('name', '') + project.description = request.data.get('description', '') + project.save() + + form_perms = request.data.get('permissions') + if form_perms is not None: + # Build perms map (ignore owners, empty usernames) + perms_map = {} + for perm in form_perms: + if not perm.get('owner') and perm.get('username'): + perms_map[perm['username']] = perm['permissions'] + + db_perms = get_users_with_perms(project, attach_perms=True, with_group_users=False) + + # Check users to remove + for user in db_perms: + + # Never modify owner permissions + if project.owner == user: + continue + + if perms_map.get(user.username) is None: + for p in db_perms[user]: + remove_perm(p, user, project) + + # Check users to add/edit + for username in perms_map: + for p in ["add", "change", "delete", "view"]: + perm = p + "_project" + user = User.objects.get(username=username) + + # Skip owners + if project.owner == user: + continue + + # Has permission in database but not in form? + if user.has_perm(perm, project) and not p in perms_map[username]: + remove_perm(perm, user, project) + + # Has permission in form but not in database? + elif p in perms_map[username] and not user.has_perm(perm, project): + assign_perm(perm, user, project) + + except User.DoesNotExist as e: + return Response({'error': _("Invalid user in permissions list")}, status=status.HTTP_400_BAD_REQUEST) + except AttributeError as e: + return Response({'error': _("Invalid permissions")}, status=status.HTTP_400_BAD_REQUEST) + + return Response({'success': True}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/app/api/tiler.py b/app/api/tiler.py index 328f6e7e..0e794b74 100644 --- a/app/api/tiler.py +++ b/app/api/tiler.py @@ -192,6 +192,7 @@ class Metadata(TaskNestedView): info['statistics'][b]['max'] = hrange[1] cmap_labels = { + "viridis": "Viridis", "jet": "Jet", "terrain": "Terrain", "gist_earth": "Earth", @@ -208,7 +209,7 @@ class Metadata(TaskNestedView): colormaps = [] algorithms = [] if tile_type in ['dsm', 'dtm']: - colormaps = ['jet', 'terrain', 'gist_earth', 'pastel1'] + colormaps = ['viridis', 'jet', 'terrain', 'gist_earth', 'pastel1'] elif formula and bands: colormaps = ['rdylgn', 'spectral', 'rdylgn_r', 'spectral_r', 'rplumbo', 'discrete_ndvi', 'better_discrete_ndvi'] diff --git a/app/api/urls.py b/app/api/urls.py index b5d6e7f7..95b244d1 100644 --- a/app/api/urls.py +++ b/app/api/urls.py @@ -6,25 +6,26 @@ from .projects import ProjectViewSet from .tasks import TaskViewSet, TaskDownloads, TaskAssets, TaskAssetsImport from .imageuploads import Thumbnail, ImageDownload from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView -from .admin import UserViewSet, GroupViewSet +from .admin import AdminUserViewSet, AdminGroupViewSet from rest_framework_nested import routers from rest_framework_jwt.views import obtain_jwt_token from .tiler import TileJson, Bounds, Metadata, Tiles, Export from .potree import Scene, CameraView from .workers import CheckTask, GetTaskResult +from .users import UsersList +from webodm import settings router = routers.DefaultRouter() router.register(r'projects', ProjectViewSet) router.register(r'processingnodes', ProcessingNodeViewSet) router.register(r'presets', PresetViewSet, base_name='presets') - tasks_router = routers.NestedSimpleRouter(router, r'projects', lookup='project') tasks_router.register(r'tasks', TaskViewSet, base_name='projects-tasks') admin_router = routers.DefaultRouter() -admin_router.register(r'admin/users', UserViewSet, basename='user') -admin_router.register(r'admin/groups', GroupViewSet, basename='group') +admin_router.register(r'admin/users', AdminUserViewSet, base_name='admin-users') +admin_router.register(r'admin/groups', AdminGroupViewSet, base_name='admin-groups') urlpatterns = [ url(r'processingnodes/options/$', ProcessingNodeOptionsView.as_view()), @@ -57,3 +58,7 @@ urlpatterns = [ url(r'^plugins/(?P[^/.]+)/(.*)$', api_view_handler) ] + +if settings.ENABLE_USERS_API: + urlpatterns.append(url(r'users', UsersList.as_view())) + diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 00000000..f2e9064d --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,24 @@ +from django.contrib.auth.models import User +from rest_framework.views import APIView +from rest_framework import exceptions, permissions, parsers +from rest_framework.response import Response + +class UsersList(APIView): + permission_classes = (permissions.IsAuthenticated,) + parser_classes = (parsers.JSONParser, parsers.FormParser,) + + def get(self, request): + qs = User.objects.all() + + search = self.request.query_params.get('search', None) + if search is not None: + qs = qs.filter(username__istartswith=search) | qs.filter(email__istartswith=search) + + limit = self.request.query_params.get('limit', None) + if limit is not None: + try: + qs = qs[:abs(int(limit))] + except ValueError: + raise exceptions.ValidationError(detail="Invalid query parameters") + + return Response([{'username': u.username, 'email': u.email} for u in qs]) \ No newline at end of file diff --git a/app/static/app/js/components/EditPermissionsPanel.jsx b/app/static/app/js/components/EditPermissionsPanel.jsx new file mode 100644 index 00000000..16059b1a --- /dev/null +++ b/app/static/app/js/components/EditPermissionsPanel.jsx @@ -0,0 +1,264 @@ +import '../css/EditPermissionsPanel.scss'; +import React from 'react'; +import ErrorMessage from './ErrorMessage'; +import PropTypes from 'prop-types'; +import $ from 'jquery'; +import { _, interpolate } from '../classes/gettext'; +import update from 'immutability-helper'; +import Css from '../classes/Css'; + +class EditPermissionsPanel extends React.Component { + static defaultProps = { + projectId: -1, + lazyLoad: true + }; + + static propTypes = { + projectId: PropTypes.number.isRequired, + lazyLoad: PropTypes.bool + }; + + constructor(props){ + super(props); + + this.state = { + error: "", + loading: false, + permissions: [], + validUsernames: {}, + validatingUser: false, + validationUnavailable: false + }; + + this.backgroundFailedColor = Css.getValue('btn-danger', 'backgroundColor'); + this.autocompleteBorderColor = Css.getValue('btn-default', 'backgroundColor'); + this.backgroundColor = Css.getValue('theme-secondary', 'backgroundColor'); + this.highlightColor = Css.getValue('theme-background-highlight', 'backgroundColor'); + } + + loadPermissions = () => { + this.setState({loading: true, permissions: []}); + + this.permsRequest = + $.getJSON(`/api/projects/${this.props.projectId}/permissions/`, json => { + let validUsernames = {}; + json.forEach(p => validUsernames[p.username] = true); + this.setState({validUsernames, permissions: json}); + }) + .fail(() => { + this.setState({error: _("Cannot load permissions.")}); + }) + .always(() => { + this.setState({loading: false}); + }); + } + + getPermissions = () => { + // Cleanup temporary objects then return + this.state.permissions.forEach(perm => delete(perm.autocomplete)); + return this.state.permissions; + } + + autocomplete = (perm) => { + if (this.validateReq){ + this.validateReq.abort(); + this.validateReq = null; + } + + if (this.validateTimeout){ + clearTimeout(this.validateTimeout); + this.validateTimeout = null; + } + + + // Empty case + if (perm.username === ""){ + delete(perm.autocomplete); + this.setState({permissions: this.state.permissions}); + return; + } + + this.setState({validatingUser: true}); + this.validateTimeout = setTimeout(() => { + this.validateReq = $.getJSON(`/api/users/?limit=30&search=${encodeURIComponent(perm.username)}`) + .done((json) => { + json.forEach(u => { + this.state.validUsernames[u.username] = true; + }); + + this.state.permissions.forEach(p => delete(p.autocomplete)); + + if (this.textFocused) perm.autocomplete = json; + + this.setState({validUsernames: this.state.validUsernames, permissions: this.state.permissions}); + }).fail(jqXHR => { + // Perhaps the user API is not enabled + if (jqXHR.statusText !== "abort"){ + this.setState({validationUnavailable: true}); + } + }).always(() => { + this.setState({validatingUser: false}); + }); + }, 300); + } + + componentDidMount(){ + if (!this.props.lazyLoad) this.loadPermissions(); + } + + componentWillUnmount(){ + if (this.permsRequest) this.permsRequest.abort(); + if (this.validateReq) this.validateReq.abort(); + if (this.validateTimeout) clearTimeout(this.validateTimeout); + } + + handleChangePermissionRole = perm => { + return e => { + perm.permissions = this.extendedPermissions(e.target.value); + this.setState({permissions: this.state.permissions}); + } + } + + handleChangePermissionUser = perm => { + return e => { + perm.username = e.target.value; + + this.autocomplete(perm); + + // Update + this.setState({permissions: this.state.permissions}); + }; + } + + simplifiedPermission = perms => { + // We simplify WebODM's internal permission model into + // a simpler read or read/write model. + if (perms.indexOf("change") !== -1) return "rw"; + else if (perms.indexOf("view") !== -1) return "r"; + else return ""; + } + + extendedPermissions = simPerm => { + if (simPerm == "rw"){ + return ["add", "change", "delete", "view"]; + }else if (simPerm == "r"){ + return ["view"]; + }else return []; + } + + permissionLabel = simPerm => { + if (simPerm === "rw") return _("Read/Write"); + else if (simPerm === "r") return _("Read"); + else if (simPerm === "") return _("No Access"); + } + + allPermissions = () => { + return ["rw", "r"].map(p => { return {key: p, label: this.permissionLabel(p)}}); + } + + getColorFor = (username) => { + if (this.state.validationUnavailable || this.state.validatingUser || this.state.validUsernames[username]) return ""; + else return this.backgroundFailedColor; + } + + addNewPermission = () => { + this.setState(update(this.state, { + permissions: {$push: [{username: "", permissions: ["view"]}]} + })); + + setTimeout(() => { + if (this.lastTextbox) this.lastTextbox.focus(); + }, 0); + } + + handleDeletePermission = perm => { + return () => { + this.setState(update(this.state, { + permissions: arr => arr.filter(p => p !== perm) + })); + } + } + + acOnMouseEnter = e => { + e.target.style.backgroundColor = this.highlightColor; + } + + acOnMouseLeave = e => { + e.target.style.backgroundColor = ""; + } + + acOnClick = (perm, acEntry) => { + return e => { + perm.username = acEntry.username; + delete(perm.autocomplete); + this.setState({permissions: this.state.permissions}); + } + } + + onFocus = e => { + this.textFocused = true; + } + + onBlur = perm => { + return e => { + delete(perm.autocomplete); + this.setState({permissions: this.state.permissions}); + this.textFocused = false; + } + } + + render() { + const permissions = this.state.permissions.map((p, i) =>
+
+
+ + this.lastTextbox = domNode} /> + {p.autocomplete && p.autocomplete.length > 0 ?
+ {p.autocomplete.map(ac =>
+
{ac.username}
+
{ac.email}
+
)} +
: ""} +
+
+ {!p.owner ? : ""} +
+
+ +
+
+ ); + + return ( +
+
+ +
+ + + {this.state.loading ? + + : [permissions,
+ +
]} +
+
+
+ ); + } +} + +export default EditPermissionsPanel; diff --git a/app/static/app/js/components/EditProjectDialog.jsx b/app/static/app/js/components/EditProjectDialog.jsx index 71efb4cc..e2932f76 100644 --- a/app/static/app/js/components/EditProjectDialog.jsx +++ b/app/static/app/js/components/EditProjectDialog.jsx @@ -2,6 +2,7 @@ import React from 'react'; import FormDialog from './FormDialog'; import PropTypes from 'prop-types'; import ErrorMessage from './ErrorMessage'; +import EditPermissionsPanel from './EditPermissionsPanel'; import { _ } from '../classes/gettext'; class EditProjectDialog extends React.Component { @@ -16,6 +17,7 @@ class EditProjectDialog extends React.Component { deleteWarning: _("All tasks, images and models associated with this project will be permanently deleted. Are you sure you want to continue?"), show: false, showDuplicate: false, + showPermissions: false, onDuplicated: () => {} }; @@ -33,6 +35,7 @@ class EditProjectDialog extends React.Component { deleteWarning: PropTypes.string, show: PropTypes.bool, showDuplicate: PropTypes.bool, + showPermissions: PropTypes.bool, onDuplicated: PropTypes.func }; @@ -62,13 +65,20 @@ class EditProjectDialog extends React.Component { } getFormData(){ - return { + const res = { name: this.state.name, descr: this.state.descr, }; + + if (this.editPermissionsPanel){ + res.permissions = this.editPermissionsPanel.getPermissions(); + } + + return res; } onShow(){ + this.editPermissionsPanel.loadPermissions(); this.nameInput.focus(); } @@ -123,7 +133,7 @@ class EditProjectDialog extends React.Component { getFormData={this.getFormData} reset={this.reset} onShow={this.onShow} - leftButtons={this.props.showDuplicate ? [] : undefined} + leftButtons={this.props.showDuplicate ? [] : undefined} ref={(domNode) => { this.dialog = domNode; }}>
@@ -138,6 +148,12 @@ class EditProjectDialog extends React.Component {