Merge pull request #3190 from normanjaeckel/PersonalNote
Added personal notes for motions
This commit is contained in:
commit
06adfa0301
@ -6,6 +6,7 @@ https://openslides.org/
|
|||||||
|
|
||||||
Version 2.2 (unreleased)
|
Version 2.2 (unreleased)
|
||||||
==========================
|
==========================
|
||||||
|
[https://github.com/OpenSlides/OpenSlides/milestones/2.2]
|
||||||
|
|
||||||
Agenda:
|
Agenda:
|
||||||
- Fixed wrong sorting of last speakers [#3193].
|
- Fixed wrong sorting of last speakers [#3193].
|
||||||
@ -13,6 +14,7 @@ Agenda:
|
|||||||
|
|
||||||
Motions:
|
Motions:
|
||||||
- New export dialog [#3185].
|
- New export dialog [#3185].
|
||||||
|
- New feature: Personal notes for motions [#3190].
|
||||||
- Fixed issue when creating/deleting motion comment fields in the
|
- Fixed issue when creating/deleting motion comment fields in the
|
||||||
settings [#3187].
|
settings [#3187].
|
||||||
- Fixed empty motion comment field in motion update form [#3194].
|
- Fixed empty motion comment field in motion update form [#3194].
|
||||||
|
@ -92,6 +92,8 @@ STATICFILES_DIRS = [
|
|||||||
|
|
||||||
AUTH_USER_MODEL = 'users.User'
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
AUTH_PERSONAL_NOTE_MODEL = 'users.PersonalNote'
|
||||||
|
|
||||||
SESSION_COOKIE_NAME = 'OpenSlidesSessionID'
|
SESSION_COOKIE_NAME = 'OpenSlidesSessionID'
|
||||||
|
|
||||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||||
|
@ -64,6 +64,14 @@ class MotionAccessPermissions(BaseAccessPermissions):
|
|||||||
except IndexError:
|
except IndexError:
|
||||||
# No data in range. Just do nothing.
|
# No data in range. Just do nothing.
|
||||||
pass
|
pass
|
||||||
|
# Now filter personal notes.
|
||||||
|
data = data.copy()
|
||||||
|
data['personal_notes'] = []
|
||||||
|
if user is not None:
|
||||||
|
for personal_note in full_data.get('personal_notes', []):
|
||||||
|
if personal_note.get('user_id') == user.id:
|
||||||
|
data['personal_notes'].append(personal_note)
|
||||||
|
break
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_projector_data(self, full_data):
|
def get_projector_data(self, full_data):
|
||||||
|
@ -50,7 +50,8 @@ class MotionManager(models.Manager):
|
|||||||
'attachments',
|
'attachments',
|
||||||
'tags',
|
'tags',
|
||||||
'submitters',
|
'submitters',
|
||||||
'supporters'))
|
'supporters',
|
||||||
|
'personal_notes'))
|
||||||
|
|
||||||
|
|
||||||
class Motion(RESTModelMixin, models.Model):
|
class Motion(RESTModelMixin, models.Model):
|
||||||
@ -170,6 +171,8 @@ class Motion(RESTModelMixin, models.Model):
|
|||||||
Configurable fields for comments. Contains a list of strings.
|
Configurable fields for comments. Contains a list of strings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
personal_notes = GenericRelation(settings.AUTH_PERSONAL_NOTE_MODEL, related_name='motions')
|
||||||
|
|
||||||
# In theory there could be one then more agenda_item. But we support only
|
# In theory there could be one then more agenda_item. But we support only
|
||||||
# one. See the property agenda_item.
|
# one. See the property agenda_item.
|
||||||
agenda_items = GenericRelation(Item, related_name='motions')
|
agenda_items = GenericRelation(Item, related_name='motions')
|
||||||
@ -639,6 +642,14 @@ class Motion(RESTModelMixin, models.Model):
|
|||||||
"""
|
"""
|
||||||
return self.agenda_item.pk
|
return self.agenda_item.pk
|
||||||
|
|
||||||
|
def set_personal_note(self, user, note=None, star=None, skip_autoupdate=False):
|
||||||
|
"""
|
||||||
|
Saves or overrides a personal note to this motion for a given user.
|
||||||
|
"""
|
||||||
|
user.set_personal_note(self, note, star)
|
||||||
|
if not skip_autoupdate:
|
||||||
|
inform_changed_data(self)
|
||||||
|
|
||||||
def write_log(self, message_list, person=None, skip_autoupdate=False):
|
def write_log(self, message_list, person=None, skip_autoupdate=False):
|
||||||
"""
|
"""
|
||||||
Write a log message.
|
Write a log message.
|
||||||
|
@ -270,6 +270,7 @@ class MotionSerializer(ModelSerializer):
|
|||||||
"""
|
"""
|
||||||
active_version = PrimaryKeyRelatedField(read_only=True)
|
active_version = PrimaryKeyRelatedField(read_only=True)
|
||||||
comments = MotionCommentsJSONSerializerField(required=False)
|
comments = MotionCommentsJSONSerializerField(required=False)
|
||||||
|
personal_notes = SerializerMethodField()
|
||||||
log_messages = MotionLogSerializer(many=True, read_only=True)
|
log_messages = MotionLogSerializer(many=True, read_only=True)
|
||||||
polls = MotionPollSerializer(many=True, read_only=True)
|
polls = MotionPollSerializer(many=True, read_only=True)
|
||||||
reason = CharField(allow_blank=True, required=False, write_only=True)
|
reason = CharField(allow_blank=True, required=False, write_only=True)
|
||||||
@ -300,6 +301,7 @@ class MotionSerializer(ModelSerializer):
|
|||||||
'submitters',
|
'submitters',
|
||||||
'supporters',
|
'supporters',
|
||||||
'comments',
|
'comments',
|
||||||
|
'personal_notes',
|
||||||
'state',
|
'state',
|
||||||
'state_required_permission_to_see',
|
'state_required_permission_to_see',
|
||||||
'workflow_id',
|
'workflow_id',
|
||||||
@ -386,6 +388,12 @@ class MotionSerializer(ModelSerializer):
|
|||||||
|
|
||||||
return motion
|
return motion
|
||||||
|
|
||||||
|
def get_personal_notes(self, motion):
|
||||||
|
"""
|
||||||
|
Returns the personal notes of all users.
|
||||||
|
"""
|
||||||
|
return [personal_note.get_data() for personal_note in motion.personal_notes.all()]
|
||||||
|
|
||||||
def get_state_required_permission_to_see(self, motion):
|
def get_state_required_permission_to_see(self, motion):
|
||||||
"""
|
"""
|
||||||
Returns the permission (as string) that is required for non
|
Returns the permission (as string) that is required for non
|
||||||
|
@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions', 'OpenSlidesApp.motions.lineNumbering'])
|
angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions', 'OpenSlidesApp.motions.lineNumbering'])
|
||||||
|
|
||||||
|
/* Generic inline editing factory.
|
||||||
|
*
|
||||||
|
* getOriginalData: Function that should return the editor data. The editor object is passed.
|
||||||
|
* saveData: Function that is called whith the editor object as argument. This function
|
||||||
|
* should prepare the save. If the function returns true, the save process won't be
|
||||||
|
* continued. Else a patch request is send.
|
||||||
|
*/
|
||||||
.factory('MotionInlineEditing', [
|
.factory('MotionInlineEditing', [
|
||||||
'Editor',
|
'Editor',
|
||||||
'Motion',
|
'Motion',
|
||||||
@ -85,30 +92,31 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
|
|||||||
};
|
};
|
||||||
|
|
||||||
obj.save = function () {
|
obj.save = function () {
|
||||||
saveData(obj);
|
if (!saveData(obj)) {
|
||||||
obj.disable();
|
obj.disable();
|
||||||
|
|
||||||
Motion.inject(motion);
|
Motion.inject(motion);
|
||||||
// save change motion object on server
|
// save change motion object on server
|
||||||
Motion.save(motion, {method: 'PATCH'}).then(
|
Motion.save(motion, {method: 'PATCH'}).then(
|
||||||
function (success) {
|
function (success) {
|
||||||
if (versioning) {
|
if (versioning) {
|
||||||
$scope.showVersion(motion.getVersion(-1));
|
$scope.showVersion(motion.getVersion(-1));
|
||||||
|
}
|
||||||
|
obj.revert();
|
||||||
|
},
|
||||||
|
function (error) {
|
||||||
|
// save error: revert all changes by restore
|
||||||
|
// (refresh) original motion object from server
|
||||||
|
Motion.refresh(motion);
|
||||||
|
obj.revert();
|
||||||
|
var message = '';
|
||||||
|
for (var e in error.data) {
|
||||||
|
message += e + ': ' + error.data[e] + ' ';
|
||||||
|
}
|
||||||
|
$scope.alert = {type: 'danger', msg: message, show: true};
|
||||||
}
|
}
|
||||||
obj.revert();
|
);
|
||||||
},
|
}
|
||||||
function (error) {
|
|
||||||
// save error: revert all changes by restore
|
|
||||||
// (refresh) original motion object from server
|
|
||||||
Motion.refresh(motion);
|
|
||||||
obj.revert();
|
|
||||||
var message = '';
|
|
||||||
for (var e in error.data) {
|
|
||||||
message += e + ': ' + error.data[e] + ' ';
|
|
||||||
}
|
|
||||||
$scope.alert = {type: 'danger', msg: message, show: true};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
|
@ -854,6 +854,7 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
'$http',
|
'$http',
|
||||||
'gettext',
|
'gettext',
|
||||||
'gettextCatalog',
|
'gettextCatalog',
|
||||||
|
'operator',
|
||||||
'ngDialog',
|
'ngDialog',
|
||||||
'MotionForm',
|
'MotionForm',
|
||||||
'Motion',
|
'Motion',
|
||||||
@ -871,10 +872,9 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
'osTableSort',
|
'osTableSort',
|
||||||
'MotionExportForm',
|
'MotionExportForm',
|
||||||
'MotionPdfExport',
|
'MotionPdfExport',
|
||||||
function($scope, $state, $http, gettext, gettextCatalog, ngDialog, MotionForm, Motion, MotionComment,
|
function($scope, $state, $http, gettext, gettextCatalog, operator, ngDialog, MotionForm, Motion,
|
||||||
Category, Config, Tag, Workflow, User, Agenda, MotionBlock, Projector,
|
MotionComment, Category, Config, Tag, Workflow, User, Agenda, MotionBlock, Projector,
|
||||||
ProjectionDefault, osTableFilter, osTableSort, MotionExportForm, MotionPdfExport) {
|
ProjectionDefault, osTableFilter, osTableSort, MotionExportForm, MotionPdfExport) {
|
||||||
Motion.bindAll({}, $scope, 'motions');
|
|
||||||
Category.bindAll({}, $scope, 'categories');
|
Category.bindAll({}, $scope, 'categories');
|
||||||
MotionBlock.bindAll({}, $scope, 'motionBlocks');
|
MotionBlock.bindAll({}, $scope, 'motionBlocks');
|
||||||
Tag.bindAll({}, $scope, 'tags');
|
Tag.bindAll({}, $scope, 'tags');
|
||||||
@ -889,6 +889,18 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
$scope.defaultProjectorId = projectiondefault.projector_id;
|
$scope.defaultProjectorId = projectiondefault.projector_id;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
$scope.$watch(function () {
|
||||||
|
return Motion.lastModified();
|
||||||
|
}, function () {
|
||||||
|
$scope.motions = Motion.getAll();
|
||||||
|
_.forEach($scope.motions, function (motion) {
|
||||||
|
motion.personalNote = _.find(motion.personal_notes, function (note) {
|
||||||
|
return note.user_id === operator.user.id;
|
||||||
|
});
|
||||||
|
// For filtering, we cannot filter for .personalNote.star
|
||||||
|
motion.star = motion.personalNote ? motion.personalNote.star : false;
|
||||||
|
});
|
||||||
|
});
|
||||||
$scope.alert = {};
|
$scope.alert = {};
|
||||||
|
|
||||||
// collect all states and all recommendations of all workflows
|
// collect all states and all recommendations of all workflows
|
||||||
@ -939,6 +951,14 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
tag: [],
|
tag: [],
|
||||||
recommendation: [],
|
recommendation: [],
|
||||||
};
|
};
|
||||||
|
$scope.filter.booleanFilters = {
|
||||||
|
isFavorite: {
|
||||||
|
value: undefined,
|
||||||
|
displayName: gettext('Favorite'),
|
||||||
|
choiceYes: gettext('Is favorite'),
|
||||||
|
choiceNo: gettext('Is not favorite'),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
updateStateFilter();
|
updateStateFilter();
|
||||||
$scope.filter.propertyList = ['identifier', 'origin'];
|
$scope.filter.propertyList = ['identifier', 'origin'];
|
||||||
@ -1062,6 +1082,16 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
}
|
}
|
||||||
$scope.save(motion);
|
$scope.save(motion);
|
||||||
};
|
};
|
||||||
|
$scope.toggleStar = function (motion) {
|
||||||
|
if (motion.personalNote) {
|
||||||
|
motion.personalNote.star = !motion.personalNote.star;
|
||||||
|
} else {
|
||||||
|
motion.personalNote = {star: true};
|
||||||
|
}
|
||||||
|
$http.put('/rest/motions/motion/' + motion.id + '/set_personal_note/',
|
||||||
|
motion.personalNote
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// open new/edit dialog
|
// open new/edit dialog
|
||||||
$scope.openDialog = function (motion) {
|
$scope.openDialog = function (motion) {
|
||||||
@ -1198,6 +1228,9 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
}, function () {
|
}, function () {
|
||||||
$scope.motion = Motion.get(motionId);
|
$scope.motion = Motion.get(motionId);
|
||||||
MotionComment.populateFields($scope.motion);
|
MotionComment.populateFields($scope.motion);
|
||||||
|
$scope.motion.personalNote = _.find($scope.motion.personal_notes, function (note) {
|
||||||
|
return note.user_id === operator.user.id;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
$scope.projectionModes = [
|
$scope.projectionModes = [
|
||||||
{mode: 'original',
|
{mode: 'original',
|
||||||
@ -1419,6 +1452,31 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
}
|
}
|
||||||
return Boolean(isAllowed);
|
return Boolean(isAllowed);
|
||||||
};
|
};
|
||||||
|
// personal note
|
||||||
|
$scope.toggleStar = function () {
|
||||||
|
if ($scope.motion.personalNote) {
|
||||||
|
$scope.motion.personalNote.star = !$scope.motion.personalNote.star;
|
||||||
|
} else {
|
||||||
|
$scope.motion.personalNote = {star: true};
|
||||||
|
}
|
||||||
|
$http.put('/rest/motions/motion/' + $scope.motion.id + '/set_personal_note/',
|
||||||
|
$scope.motion.personalNote
|
||||||
|
);
|
||||||
|
};
|
||||||
|
$scope.changePN = function () {
|
||||||
|
};
|
||||||
|
|
||||||
|
// personal note
|
||||||
|
$scope.toggleStar = function () {
|
||||||
|
if ($scope.motion.personalNote) {
|
||||||
|
$scope.motion.personalNote.star = !$scope.motion.personalNote.star;
|
||||||
|
} else {
|
||||||
|
$scope.motion.personalNote = {star: true};
|
||||||
|
}
|
||||||
|
$http.put('/rest/motions/motion/' + $scope.motion.id + '/set_personal_note/',
|
||||||
|
$scope.motion.personalNote
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Inline editing functions
|
// Inline editing functions
|
||||||
$scope.inlineEditing = MotionInlineEditing.createInstance($scope, motion,
|
$scope.inlineEditing = MotionInlineEditing.createInstance($scope, motion,
|
||||||
@ -1434,6 +1492,25 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
$scope.commentsInlineEditing = MotionCommentsInlineEditing.createInstances($scope, motion);
|
$scope.commentsInlineEditing = MotionCommentsInlineEditing.createInstances($scope, motion);
|
||||||
|
$scope.personalNoteInlineEditing = MotionInlineEditing.createInstance($scope, motion,
|
||||||
|
'personal-note-inline-editor', false,
|
||||||
|
function (obj) {
|
||||||
|
return motion.personalNote ? motion.personalNote.note : '';
|
||||||
|
},
|
||||||
|
function (obj) {
|
||||||
|
if (motion.personalNote) {
|
||||||
|
motion.personalNote.note = obj.editor.getData();
|
||||||
|
} else {
|
||||||
|
motion.personalNote = {note: obj.editor.getData()};
|
||||||
|
}
|
||||||
|
$http.put('/rest/motions/motion/' + $scope.motion.id + '/set_personal_note/',
|
||||||
|
motion.personalNote
|
||||||
|
);
|
||||||
|
obj.revert();
|
||||||
|
obj.disable();
|
||||||
|
return true; // Do not update the motion via patch request.
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Change recommendation creation functions
|
// Change recommendation creation functions
|
||||||
$scope.createChangeRecommendation = ChangeRecommmendationCreate;
|
$scope.createChangeRecommendation = ChangeRecommmendationCreate;
|
||||||
|
@ -59,7 +59,12 @@
|
|||||||
<translate>PDF</translate>
|
<translate>PDF</translate>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<h1>{{ motion.agenda_item.getTitle() || motion.getTitle() }}</h1>
|
<h1>
|
||||||
|
{{ motion.agenda_item.getTitle() || motion.getTitle() }}
|
||||||
|
<i class="fa pointer" ng-class="motion.personalNote.star ? 'fa-star' : 'fa-star-o'"
|
||||||
|
ng-if="operator.user"
|
||||||
|
title="{{ 'Set as favorite' | translate }}" ng-click="toggleStar()"></i>
|
||||||
|
</h1>
|
||||||
<h2>
|
<h2>
|
||||||
<translate>Motion</translate> {{ motion.identifier }}
|
<translate>Motion</translate> {{ motion.identifier }}
|
||||||
<span ng-if="parent">
|
<span ng-if="parent">
|
||||||
@ -542,3 +547,6 @@
|
|||||||
|
|
||||||
<!-- Comments section -->
|
<!-- Comments section -->
|
||||||
<ng-include src="'static/templates/motions/motion-detail/comments.html'"></ng-include>
|
<ng-include src="'static/templates/motions/motion-detail/comments.html'"></ng-include>
|
||||||
|
|
||||||
|
<!-- Personal note section -->
|
||||||
|
<ng-include src="'static/templates/motions/motion-detail/personal-note.html'" ng-if="operator.user"></ng-include>
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
<div class="details">
|
||||||
|
<div class="row">
|
||||||
|
<!-- inline editing toolbar -->
|
||||||
|
<div class="motion-toolbar">
|
||||||
|
<div class="pull-right inline-editing-activator">
|
||||||
|
<button ng-if="!personalNoteInlineEditing.active" ng-click="personalNoteInlineEditing.enable()"
|
||||||
|
class="btn btn-sm btn-default">
|
||||||
|
<i class="fa fa-pencil-square-o"></i>
|
||||||
|
<translate>Inline editing</translate>
|
||||||
|
</button>
|
||||||
|
<button ng-if="personalNoteInlineEditing.active" ng-click="personalNoteInlineEditing.disable()"
|
||||||
|
class="btn btn-sm btn-default">
|
||||||
|
<i class="fa fa-times-circle"></i>
|
||||||
|
<translate>Inline editing</translate>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h3 class="toolbar-left" translate>Personal note</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- personal note editor field -->
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<div id="personal-note-inline-editor" style="min-height: 14px;"
|
||||||
|
ng-bind-html="motion.personalNote.note | trusted" contenteditable="{{ personalNoteInlineEditing.isEditable }}"></div>
|
||||||
|
<!-- save toolbar -->
|
||||||
|
<div class="motion-save-toolbar" ng-class="{ 'visible': (personalNoteInlineEditing.changed && personalNoteInlineEditing.active) }">
|
||||||
|
<div class="changed-hint" translate>The personal note has been changed.</div>
|
||||||
|
<button type="button" ng-click="personalNoteInlineEditing.save()" class="btn btn-primary" translate>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button type="button" ng-click="personalNoteInlineEditing.revert()" class="btn btn-default" translate>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -268,6 +268,30 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- boolean Filters -->
|
||||||
|
<span ng-repeat="(name, booleanFilter) in filter.booleanFilters"
|
||||||
|
ng-if="operator.user.id" uib-dropdown>
|
||||||
|
<span class="pointer" id="dropdown{{ name }}" uib-dropdown-toggle
|
||||||
|
ng-class="{'bold': booleanFilter.value !== undefined, 'disabled': isSelectMode}"
|
||||||
|
ng-disabled="isSelectMode">
|
||||||
|
{{ booleanFilter.displayName | translate }}
|
||||||
|
<span class="caret"></span>
|
||||||
|
</span>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown{{ name }}">
|
||||||
|
<li>
|
||||||
|
<a href ng-click="booleanFilter.value = (booleanFilter.value ? undefined : true); filter.save();">
|
||||||
|
<i class="fa" ng-class="{'fa-check': booleanFilter.value === true}"></i>
|
||||||
|
{{ booleanFilter.choiceYes | translate }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href ng-click="booleanFilter.value = (booleanFilter.value === false) ? undefined : false; filter.save();">
|
||||||
|
<i class="fa" ng-class="{'fa-check': booleanFilter.value === false}"></i>
|
||||||
|
{{ booleanFilter.choiceNo | translate }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
<!-- dropdown sort -->
|
<!-- dropdown sort -->
|
||||||
<span uib-dropdown>
|
<span uib-dropdown>
|
||||||
<span class="pointer" id="dropdownSort" uib-dropdown-toggle
|
<span class="pointer" id="dropdownSort" uib-dropdown-toggle
|
||||||
@ -387,6 +411,17 @@
|
|||||||
<i class="fa fa-times-circle"></i>
|
<i class="fa fa-times-circle"></i>
|
||||||
<translate>No tag set</translate>
|
<translate>No tag set</translate>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- for all boolean Filters -->
|
||||||
|
<span ng-repeat="(name, booleanFilter) in filter.booleanFilters"
|
||||||
|
ng-hide="booleanFilter.value === undefined"
|
||||||
|
class="pointer spacer-left-lg"
|
||||||
|
ng-click="booleanFilter.value = undefined; filter.save();"
|
||||||
|
ng-class="{'disabled': isSelectMode}">
|
||||||
|
<span class="nobr">
|
||||||
|
<i class="fa fa-times-circle"></i>
|
||||||
|
{{ booleanFilter.value ? booleanFilter.choiceYes : booleanFilter.choiceNo | translate }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -402,6 +437,7 @@
|
|||||||
| MultiselectFilter: filter.multiselectFilters.motionBlock : getItemId.motionBlock
|
| MultiselectFilter: filter.multiselectFilters.motionBlock : getItemId.motionBlock
|
||||||
| MultiselectFilter: filter.multiselectFilters.recommendation : getItemId.recommendation
|
| MultiselectFilter: filter.multiselectFilters.recommendation : getItemId.recommendation
|
||||||
| MultiselectFilter: filter.multiselectFilters.tag : getItemId.tag
|
| MultiselectFilter: filter.multiselectFilters.tag : getItemId.tag
|
||||||
|
| filter: {star: filter.booleanFilters.isFavorite.value}
|
||||||
| toArray
|
| toArray
|
||||||
| orderBy: sort.column : sort.reverse)
|
| orderBy: sort.column : sort.reverse)
|
||||||
| limitTo : itemsPerPage : limitBegin">
|
| limitTo : itemsPerPage : limitBegin">
|
||||||
@ -427,7 +463,11 @@
|
|||||||
<div class="id-col-space">
|
<div class="id-col-space">
|
||||||
<!-- ID and title -->
|
<!-- ID and title -->
|
||||||
<div>
|
<div>
|
||||||
<a class="title" ui-sref="motions.motion.detail({id: motion.id})">{{ motion.getTitle() }}</a>
|
<a class="title" ui-sref="motions.motion.detail({id: motion.id})">{{ motion.getTitle() }}</a>
|
||||||
|
<a href="" ng-click="toggleStar(motion)">
|
||||||
|
<i class="fa" ng-class="motion.personalNote.star ? 'fa-star' : 'fa-star-o'"
|
||||||
|
title="{{ 'Set as favorite' | translate }}" ng-if="(motion.personalNote.star || motion.hover) && operator.user"></i>
|
||||||
|
</a>
|
||||||
<i class="fa fa-paperclip" ng-if="motion.attachments_id.length > 0"></i>
|
<i class="fa fa-paperclip" ng-if="motion.attachments_id.length > 0"></i>
|
||||||
</div>
|
</div>
|
||||||
<!-- state -->
|
<!-- state -->
|
||||||
|
@ -63,7 +63,7 @@ class MotionViewSet(ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
if self.action in ('list', 'retrieve'):
|
if self.action in ('list', 'retrieve'):
|
||||||
result = self.get_access_permissions().check_permissions(self.request.user)
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
||||||
elif self.action in ('metadata', 'partial_update', 'update'):
|
elif self.action in ('metadata', 'partial_update', 'update', 'set_personal_note'):
|
||||||
result = has_perm(self.request.user, 'motions.can_see')
|
result = has_perm(self.request.user, 'motions.can_see')
|
||||||
# For partial_update and update requests the rest of the check is
|
# For partial_update and update requests the rest of the check is
|
||||||
# done in the update method. See below.
|
# done in the update method. See below.
|
||||||
@ -355,6 +355,20 @@ class MotionViewSet(ModelViewSet):
|
|||||||
person=request.user)
|
person=request.user)
|
||||||
return Response({'detail': message})
|
return Response({'detail': message})
|
||||||
|
|
||||||
|
@detail_route(methods=['put'])
|
||||||
|
def set_personal_note(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Special view endpoint to save a personal note to a motion.
|
||||||
|
|
||||||
|
Send PUT with {'note': <note>, 'star': True|False}.
|
||||||
|
"""
|
||||||
|
motion = self.get_object()
|
||||||
|
if not request.user.is_authenticated():
|
||||||
|
raise ValidationError({'detail': _('Anonymous users are not able to set personal notes.')})
|
||||||
|
motion.set_personal_note(request.user, request.data.get('note'), bool(request.data.get('star')))
|
||||||
|
message = _('You set yopur personal notes successfully.')
|
||||||
|
return Response({'detail': message})
|
||||||
|
|
||||||
@detail_route(methods=['post'])
|
@detail_route(methods=['post'])
|
||||||
def create_poll(self, request, pk=None):
|
def create_poll(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
|
30
openslides/users/migrations/0004_personalnote.py
Normal file
30
openslides/users/migrations/0004_personalnote.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.10.6 on 2017-04-07 14:15
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('users', '0003_group'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PersonalNote',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('object_id', models.PositiveIntegerField()),
|
||||||
|
('note', models.TextField(blank=True)),
|
||||||
|
('star', models.BooleanField(default=False)),
|
||||||
|
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='personal_notes', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -9,6 +9,8 @@ from django.contrib.auth.models import (
|
|||||||
Permission,
|
Permission,
|
||||||
PermissionsMixin,
|
PermissionsMixin,
|
||||||
)
|
)
|
||||||
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Prefetch, Q
|
from django.db.models import Prefetch, Q
|
||||||
|
|
||||||
@ -17,6 +19,36 @@ from ..utils.models import RESTModelMixin
|
|||||||
from .access_permissions import GroupAccessPermissions, UserAccessPermissions
|
from .access_permissions import GroupAccessPermissions, UserAccessPermissions
|
||||||
|
|
||||||
|
|
||||||
|
class PersonalNote(models.Model):
|
||||||
|
"""
|
||||||
|
Model for personal notes and likes (stars) of a user concerning different
|
||||||
|
openslides models like motions.
|
||||||
|
|
||||||
|
To use this in your app simply run e. g.
|
||||||
|
|
||||||
|
user.set_personal_note(motion, note, star)
|
||||||
|
|
||||||
|
in a setter view and add a SerializerMethodField to your serializer that
|
||||||
|
calls get_data for all users.
|
||||||
|
"""
|
||||||
|
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name='personal_notes')
|
||||||
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
object_id = models.PositiveIntegerField()
|
||||||
|
content_object = GenericForeignKey()
|
||||||
|
note = models.TextField(blank=True)
|
||||||
|
star = models.BooleanField(default=False, blank=True)
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
"""
|
||||||
|
Returns note and star to be serialized in content object serializers.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'note': self.note,
|
||||||
|
'star': self.star,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
"""
|
"""
|
||||||
Customized manager that creates new users only with a password and a
|
Customized manager that creates new users only with a password and a
|
||||||
@ -211,6 +243,29 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
|
|||||||
"""
|
"""
|
||||||
raise RuntimeError('Do not use user.has_perm() but use openslides.utils.auth.has_perm')
|
raise RuntimeError('Do not use user.has_perm() but use openslides.utils.auth.has_perm')
|
||||||
|
|
||||||
|
def set_personal_note(self, content_object, note=None, star=None):
|
||||||
|
"""
|
||||||
|
Saves or overrides a personal note for this user for a given object
|
||||||
|
like motion.
|
||||||
|
"""
|
||||||
|
changes = {}
|
||||||
|
if note is not None:
|
||||||
|
changes['note'] = note
|
||||||
|
if star is not None:
|
||||||
|
changes['star'] = star
|
||||||
|
if changes:
|
||||||
|
# TODO: This is prone to race-conditions in rare cases. Fix it.
|
||||||
|
personal_note, created = PersonalNote.objects.update_or_create(
|
||||||
|
user=self,
|
||||||
|
content_type=ContentType.objects.get_for_model(content_object),
|
||||||
|
object_id=content_object.id,
|
||||||
|
user_id=self.id,
|
||||||
|
defaults=changes,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
personal_note = None
|
||||||
|
return personal_note
|
||||||
|
|
||||||
|
|
||||||
class GroupManager(GroupManager):
|
class GroupManager(GroupManager):
|
||||||
"""
|
"""
|
||||||
|
@ -538,7 +538,6 @@ angular.module('OpenSlidesApp.users.site', [
|
|||||||
choiceYes: gettext('Is a committee'),
|
choiceYes: gettext('Is a committee'),
|
||||||
choiceNo: gettext('Is not a committee'),
|
choiceNo: gettext('Is not a committee'),
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
$scope.filter.propertyList = ['first_name', 'last_name', 'title', 'number', 'comment', 'structure_level'];
|
$scope.filter.propertyList = ['first_name', 'last_name', 'title', 'number', 'comment', 'structure_level'];
|
||||||
|
@ -25,6 +25,9 @@ class TestMotionDBQueries(TestCase):
|
|||||||
config['general_system_enable_anonymous'] = True
|
config['general_system_enable_anonymous'] = True
|
||||||
for index in range(10):
|
for index in range(10):
|
||||||
Motion.objects.create(title='motion{}'.format(index))
|
Motion.objects.create(title='motion{}'.format(index))
|
||||||
|
get_user_model().objects.create_user(
|
||||||
|
username='user_{}'.format(index),
|
||||||
|
password='password')
|
||||||
# TODO: Create some polls etc.
|
# TODO: Create some polls etc.
|
||||||
|
|
||||||
@use_cache()
|
@use_cache()
|
||||||
@ -39,10 +42,11 @@ class TestMotionDBQueries(TestCase):
|
|||||||
* 1 request to get the polls,
|
* 1 request to get the polls,
|
||||||
* 1 request to get the attachments,
|
* 1 request to get the attachments,
|
||||||
* 1 request to get the tags,
|
* 1 request to get the tags,
|
||||||
* 2 requests to get the submitters and supporters and
|
* 2 requests to get the submitters and supporters,
|
||||||
|
* 1 requests to get the personal notes.
|
||||||
"""
|
"""
|
||||||
self.client.force_login(get_user_model().objects.get(pk=1))
|
self.client.force_login(get_user_model().objects.get(pk=1))
|
||||||
with self.assertNumQueries(14):
|
with self.assertNumQueries(15):
|
||||||
self.client.get(reverse('motion-list'))
|
self.client.get(reverse('motion-list'))
|
||||||
|
|
||||||
@use_cache()
|
@use_cache()
|
||||||
@ -57,9 +61,10 @@ class TestMotionDBQueries(TestCase):
|
|||||||
* 1 request to get the polls,
|
* 1 request to get the polls,
|
||||||
* 1 request to get the attachments,
|
* 1 request to get the attachments,
|
||||||
* 1 request to get the tags,
|
* 1 request to get the tags,
|
||||||
* 2 requests to get the submitters and supporters
|
* 2 requests to get the submitters and supporters,
|
||||||
|
* 1 request to get the personal notes.
|
||||||
"""
|
"""
|
||||||
with self.assertNumQueries(13):
|
with self.assertNumQueries(14):
|
||||||
self.client.get(reverse('motion-list'))
|
self.client.get(reverse('motion-list'))
|
||||||
|
|
||||||
|
|
||||||
@ -378,10 +383,29 @@ class RetrieveMotion(TestCase):
|
|||||||
text='test_text_ithohchaeThohmae5aug')
|
text='test_text_ithohchaeThohmae5aug')
|
||||||
self.motion.save()
|
self.motion.save()
|
||||||
self.motion.create_poll()
|
self.motion.create_poll()
|
||||||
|
for index in range(10):
|
||||||
|
get_user_model().objects.create_user(
|
||||||
|
username='user_{}'.format(index),
|
||||||
|
password='password')
|
||||||
|
|
||||||
@use_cache()
|
@use_cache()
|
||||||
def test_number_of_queries(self):
|
def test_number_of_queries(self):
|
||||||
with self.assertNumQueries(18):
|
"""
|
||||||
|
Tests that only the following db queries are done:
|
||||||
|
* 7 requests to get the session and the request user with its permissions (3 of them are possibly a bug)
|
||||||
|
* 1 request to get the motion,
|
||||||
|
* 1 request to get the version,
|
||||||
|
* 1 request to get the agenda item,
|
||||||
|
* 1 request to get the log,
|
||||||
|
* 3 request to get the polls (1 of them is possibly a bug),
|
||||||
|
* 1 request to get the attachments,
|
||||||
|
* 1 request to get the tags,
|
||||||
|
* 2 requests to get the submitters and supporters,
|
||||||
|
* 1 request to get personal notes.
|
||||||
|
|
||||||
|
TODO: Fix all bugs.
|
||||||
|
"""
|
||||||
|
with self.assertNumQueries(19):
|
||||||
self.client.get(reverse('motion-detail', args=[self.motion.pk]))
|
self.client.get(reverse('motion-detail', args=[self.motion.pk]))
|
||||||
|
|
||||||
def test_guest_state_with_required_permission_to_see(self):
|
def test_guest_state_with_required_permission_to_see(self):
|
||||||
@ -434,6 +458,14 @@ class RetrieveMotion(TestCase):
|
|||||||
response_3 = guest_client.get(reverse('user-detail', args=[extra_user.pk]))
|
response_3 = guest_client.get(reverse('user-detail', args=[extra_user.pk]))
|
||||||
self.assertEqual(response_3.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response_3.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
def test_anonymous_without_personal_notes(self):
|
||||||
|
self.motion.set_personal_note(get_user_model().objects.get(pk=1), note='admin_personal_note_OoGh8choro0oosh0roob')
|
||||||
|
config['general_system_enable_anonymous'] = True
|
||||||
|
guest_client = APIClient()
|
||||||
|
response = guest_client.get(reverse('motion-detail', args=[self.motion.pk]))
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertNotContains(response, 'admin_personal_note_OoGh8choro0oosh0roob')
|
||||||
|
|
||||||
|
|
||||||
class UpdateMotion(TestCase):
|
class UpdateMotion(TestCase):
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user