Motion rework
- remove motion version - migrations for versions and change recommendations - Redone motion comments. Wording changed from comment fields to comment sections - fixed test order, tests are not atomic - introduce get_group_model. Just use OpenSlides Groups and not the django's ones.
This commit is contained in:
parent
3b57fbcd8c
commit
f1ddd16dc6
@ -20,8 +20,8 @@ install:
|
||||
- pip freeze
|
||||
- cd client && npm install && cd ..
|
||||
script:
|
||||
- cd client && npm run-script lint && cd ..
|
||||
- flake8 openslides tests
|
||||
- isort --check-only --diff --recursive openslides tests
|
||||
- python -m mypy openslides/
|
||||
- pytest --cov --cov-fail-under=70
|
||||
- cd client && npm run-script lint && cd ..
|
||||
- pytest tests/old/ tests/integration/ tests/unit/ --cov --cov-fail-under=75
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { BaseModel } from '../base.model';
|
||||
import { MotionVersion } from './motion-version';
|
||||
import { MotionSubmitter } from './motion-submitter';
|
||||
import { MotionLog } from './motion-log';
|
||||
import { Config } from '../core/config';
|
||||
@ -19,18 +18,23 @@ export class Motion extends BaseModel {
|
||||
protected _collectionString: string;
|
||||
public id: number;
|
||||
public identifier: string;
|
||||
public versions: MotionVersion[];
|
||||
public active_version: number;
|
||||
public title: string;
|
||||
public text: string;
|
||||
public reason: string;
|
||||
public amendment_paragraphs: string;
|
||||
public modified_final_version: string;
|
||||
public parent_id: number;
|
||||
public category_id: number;
|
||||
public motion_block_id: number;
|
||||
public origin: string;
|
||||
public submitters: MotionSubmitter[];
|
||||
public supporters_id: number[];
|
||||
public comments: Object;
|
||||
public comments: Object[];
|
||||
public state_id: number;
|
||||
public state_extension: string;
|
||||
public state_required_permission_to_see: string;
|
||||
public recommendation_id: number;
|
||||
public recommendation_extension: string;
|
||||
public tags_id: number[];
|
||||
public attachments_id: number[];
|
||||
public polls: BaseModel[];
|
||||
@ -40,15 +44,14 @@ export class Motion extends BaseModel {
|
||||
// dynamic values
|
||||
public workflow: Workflow;
|
||||
|
||||
// for request
|
||||
public title: string;
|
||||
public text: string;
|
||||
|
||||
public constructor(input?: any) {
|
||||
super();
|
||||
this._collectionString = 'motions/motion';
|
||||
this.identifier = '';
|
||||
this.versions = [new MotionVersion()];
|
||||
this.title = '';
|
||||
this.text = '';
|
||||
this.reason = '';
|
||||
this.modified_final_version = '';
|
||||
this.origin = '';
|
||||
this.submitters = [];
|
||||
this.supporters_id = [];
|
||||
@ -101,62 +104,6 @@ export class Motion extends BaseModel {
|
||||
console.log('did addSubmitter. this.submitters: ', this.submitters);
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the most current title from versions
|
||||
*/
|
||||
public get currentTitle(): string {
|
||||
if (this.versions && this.versions[0]) {
|
||||
return this.versions[0].title;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the current version
|
||||
*
|
||||
* TODO: Altering the current version should be avoided.
|
||||
*/
|
||||
public set currentTitle(newTitle: string) {
|
||||
if (this.versions[0]) {
|
||||
this.versions[0].title = newTitle;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the most current motion text from versions
|
||||
*/
|
||||
public get currentText() {
|
||||
if (this.versions) {
|
||||
return this.versions[0].text;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public set currentText(newText: string) {
|
||||
this.versions[0].text = newText;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the most current motion reason text from versions
|
||||
*/
|
||||
public get currentReason() {
|
||||
if (this.versions) {
|
||||
return this.versions[0].reason;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current reason.
|
||||
* TODO: ignores motion versions. Should make a new one.
|
||||
*/
|
||||
public set currentReason(newReason: string) {
|
||||
this.versions[0].reason = newReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* return the submitters as uses objects
|
||||
*/
|
||||
@ -239,13 +186,6 @@ export class Motion extends BaseModel {
|
||||
public deserialize(input: any): void {
|
||||
Object.assign(this, input);
|
||||
|
||||
if (input.versions instanceof Array) {
|
||||
this.versions = [];
|
||||
input.versions.forEach(motionVersionData => {
|
||||
this.versions.push(new MotionVersion(motionVersionData));
|
||||
});
|
||||
}
|
||||
|
||||
if (input.submitters instanceof Array) {
|
||||
this.submitters = [];
|
||||
input.submitters.forEach(SubmitterData => {
|
||||
|
@ -12,8 +12,8 @@
|
||||
<span *ngIf="motion && !editMotion"> {{motion.identifier}}</span>
|
||||
<span *ngIf="editMotion && !newMotion"> {{metaInfoForm.get('identifier').value}}</span>
|
||||
<span>:</span>
|
||||
<span *ngIf="motion && !editMotion"> {{motion.currentTitle}}</span>
|
||||
<span *ngIf="editMotion"> {{contentForm.get('currentTitle').value}}</span>
|
||||
<span *ngIf="motion && !editMotion"> {{motion.title}}</span>
|
||||
<span *ngIf="editMotion"> {{contentForm.get('title').value}}</span>
|
||||
<br>
|
||||
<div *ngIf="motion" class='motion-submitter'>
|
||||
<span translate>by</span> {{motion.submitterAsUser}}
|
||||
@ -223,12 +223,12 @@
|
||||
<form [formGroup]='contentForm' (ngSubmit)='saveMotion()'>
|
||||
|
||||
<!-- Title -->
|
||||
<div *ngIf="motion && motion.currentTitle || editMotion">
|
||||
<div *ngIf="motion && motion.title || editMotion">
|
||||
<div *ngIf='!editMotion'>
|
||||
<h2>{{motion.currentTitle}}</h2>
|
||||
<h2>{{motion.title}}</h2>
|
||||
</div>
|
||||
<mat-form-field *ngIf="editMotion" class="wide-form">
|
||||
<input matInput placeholder='Title' formControlName='currentTitle' [value]='motionCopy.currentTitle'>
|
||||
<input matInput placeholder='Title' formControlName='title' [value]='motionCopy.title'>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
@ -237,21 +237,21 @@
|
||||
<!-- TODO: this is a config variable. Read it out -->
|
||||
<h3 translate>The assembly may decide:</h3>
|
||||
<div *ngIf='motion && !editMotion'>
|
||||
<div [innerHtml]='motion.currentText'></div>
|
||||
<div [innerHtml]='motion.text'></div>
|
||||
</div>
|
||||
<mat-form-field *ngIf="motion && editMotion" class="wide-form">
|
||||
<textarea matInput placeholder='Motion Text' formControlName='currentText' [value]='motionCopy.currentText'></textarea>
|
||||
<textarea matInput placeholder='Motion Text' formControlName='text' [value]='motionCopy.text'></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
|
||||
<!-- Reason -->
|
||||
<div *ngIf="motion && motion.currentReason || editMotion">
|
||||
<div *ngIf="motion && motion.reason || editMotion">
|
||||
<div *ngIf='!editMotion'>
|
||||
<h4 translate>Reason</h4>
|
||||
<div [innerHtml]='motion.currentReason'></div>
|
||||
<div [innerHtml]='motion.reason'></div>
|
||||
</div>
|
||||
<mat-form-field *ngIf="editMotion" class="wide-form">
|
||||
<textarea matInput placeholder="Reason" formControlName='currentReason' [value]='motionCopy.currentReason'></textarea>
|
||||
<textarea matInput placeholder="Reason" formControlName='reason' [value]='motionCopy.reason'></textarea>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
|
@ -114,9 +114,9 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
||||
origin: formMotion.origin
|
||||
});
|
||||
this.contentForm.patchValue({
|
||||
currentTitle: formMotion.currentTitle,
|
||||
currentText: formMotion.currentText,
|
||||
currentReason: formMotion.currentReason
|
||||
title: formMotion.title,
|
||||
text: formMotion.text,
|
||||
reason: formMotion.reason
|
||||
});
|
||||
}
|
||||
|
||||
@ -134,9 +134,9 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
||||
origin: ['']
|
||||
});
|
||||
this.contentForm = this.formBuilder.group({
|
||||
currentTitle: ['', Validators.required],
|
||||
currentText: ['', Validators.required],
|
||||
currentReason: ['']
|
||||
title: ['', Validators.required],
|
||||
text: ['', Validators.required],
|
||||
reason: ['']
|
||||
});
|
||||
}
|
||||
|
||||
@ -153,10 +153,6 @@ export class MotionDetailComponent extends BaseComponent implements OnInit {
|
||||
const newMotionValues = { ...this.metaInfoForm.value, ...this.contentForm.value };
|
||||
this.motionCopy.patchValues(newMotionValues);
|
||||
|
||||
// TODO: This is DRAFT. Reads out Motion version directly. Potentially insecure.
|
||||
this.motionCopy.title = this.motionCopy.currentTitle;
|
||||
this.motionCopy.text = this.motionCopy.currentText;
|
||||
|
||||
// TODO: send to normal motion to verify
|
||||
this.dataSend.saveModel(this.motionCopy).subscribe(answer => {
|
||||
if (answer && answer.id && this.newMotion) {
|
||||
|
@ -8,7 +8,6 @@ import { TranslateService } from '@ngx-translate/core'; // showcase
|
||||
import { User } from 'app/shared/models/users/user';
|
||||
import { Config } from '../../shared/models/core/config';
|
||||
import { Motion } from '../../shared/models/motions/motion';
|
||||
import { MotionVersion } from '../../shared/models/motions/motion-version';
|
||||
import { MotionSubmitter } from '../../shared/models/motions/motion-submitter';
|
||||
|
||||
@Component({
|
||||
@ -152,15 +151,6 @@ export class StartComponent extends BaseComponent implements OnInit {
|
||||
`;
|
||||
|
||||
for (let i = 1; i <= requiredMotions; ++i) {
|
||||
// version
|
||||
const newMotionVersion = new MotionVersion({
|
||||
id: 200 + i,
|
||||
version_number: 1,
|
||||
create_time: 'now',
|
||||
title: 'GenMo ' + i,
|
||||
text: longMotionText,
|
||||
reason: longMotionText
|
||||
});
|
||||
// submitter
|
||||
const newMotionSubmitter = new MotionSubmitter({
|
||||
id: 1,
|
||||
@ -172,7 +162,9 @@ export class StartComponent extends BaseComponent implements OnInit {
|
||||
const newMotion = new Motion({
|
||||
id: 200 + i,
|
||||
identifier: 'GenMo ' + i,
|
||||
versions: [newMotionVersion],
|
||||
title: 'title',
|
||||
text: longMotionText,
|
||||
reason: longMotionText,
|
||||
origin: 'Generated',
|
||||
submitters: [newMotionSubmitter],
|
||||
state_id: 1
|
||||
|
@ -27,7 +27,6 @@ INPUT_TYPE_MAPPING = {
|
||||
'integer': int,
|
||||
'boolean': bool,
|
||||
'choice': str,
|
||||
'comments': dict,
|
||||
'colorpicker': str,
|
||||
'datetimepicker': int,
|
||||
'majorityMethod': str,
|
||||
@ -133,31 +132,6 @@ class ConfigHandler:
|
||||
except DjangoValidationError as e:
|
||||
raise ConfigError(e.messages[0])
|
||||
|
||||
if config_variable.input_type == 'comments':
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigError(_('motions_comments has to be a dict.'))
|
||||
valuecopy = dict()
|
||||
for id, commentsfield in value.items():
|
||||
try:
|
||||
id = int(id)
|
||||
except ValueError:
|
||||
raise ConfigError(_('Each id has to be an int.'))
|
||||
|
||||
if id < 1:
|
||||
raise ConfigError(_('Each id has to be greater then 0.'))
|
||||
# Deleted commentsfields are saved as None to block the used ids
|
||||
if commentsfield is not None:
|
||||
if not isinstance(commentsfield, dict):
|
||||
raise ConfigError(_('Each commentsfield in motions_comments has to be a dict.'))
|
||||
if commentsfield.get('name') is None or commentsfield.get('public') is None:
|
||||
raise ConfigError(_('A name and a public property have to be given.'))
|
||||
if not isinstance(commentsfield['name'], str):
|
||||
raise ConfigError(_('name has to be string.'))
|
||||
if not isinstance(commentsfield['public'], bool):
|
||||
raise ConfigError(_('public property has to be bool.'))
|
||||
valuecopy[id] = commentsfield
|
||||
value = valuecopy
|
||||
|
||||
if config_variable.input_type == 'static':
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigError(_('This has to be a dict.'))
|
||||
|
@ -99,6 +99,8 @@ STATICFILES_DIRS = [
|
||||
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
|
||||
AUTH_GROUP_MODEL = 'users.Group'
|
||||
|
||||
SESSION_COOKIE_NAME = 'OpenSlidesSessionID'
|
||||
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
@ -1,9 +1,8 @@
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..core.config import config
|
||||
from ..utils.access_permissions import BaseAccessPermissions
|
||||
from ..utils.auth import has_perm
|
||||
from ..utils.auth import has_perm, in_some_groups
|
||||
from ..utils.collection import CollectionElement
|
||||
|
||||
|
||||
@ -32,7 +31,7 @@ class MotionAccessPermissions(BaseAccessPermissions):
|
||||
"""
|
||||
Returns the restricted serialized data for the instance prepared for
|
||||
the user. Removes motion if the user has not the permission to see
|
||||
the motion in this state. Removes non public comment fields for
|
||||
the motion in this state. Removes comments sections for
|
||||
some unauthorized users. Ensures that a user can only see his own
|
||||
personal notes.
|
||||
"""
|
||||
@ -59,21 +58,12 @@ class MotionAccessPermissions(BaseAccessPermissions):
|
||||
|
||||
# Parse single motion.
|
||||
if permission:
|
||||
if has_perm(user, 'motions.can_see_comments') or not full.get('comments'):
|
||||
# Provide access to all fields.
|
||||
motion = full
|
||||
else:
|
||||
# Set private comment fields to None.
|
||||
full_copy = deepcopy(full)
|
||||
for i, field in config['motions_comments'].items():
|
||||
if field is None or not field.get('public'):
|
||||
try:
|
||||
full_copy['comments'][i] = None
|
||||
except IndexError:
|
||||
# No data in range. Just do nothing.
|
||||
pass
|
||||
motion = full_copy
|
||||
data.append(motion)
|
||||
full_copy = deepcopy(full)
|
||||
full_copy['comments'] = []
|
||||
for comment in full['comments']:
|
||||
if in_some_groups(user, comment['read_groups_id']):
|
||||
full_copy['comments'].append(comment)
|
||||
data.append(full_copy)
|
||||
else:
|
||||
data = []
|
||||
|
||||
@ -82,25 +72,13 @@ class MotionAccessPermissions(BaseAccessPermissions):
|
||||
def get_projector_data(self, full_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns the restricted serialized data for the instance prepared
|
||||
for the projector. Removes several comment fields.
|
||||
for the projector. Removes all comments.
|
||||
"""
|
||||
# Parse data.
|
||||
data = []
|
||||
for full in full_data:
|
||||
# Set private comment fields to None.
|
||||
if full.get('comments') is not None:
|
||||
full_copy = deepcopy(full)
|
||||
for i, field in config['motions_comments'].items():
|
||||
if field is None or not field.get('public'):
|
||||
try:
|
||||
full_copy['comments'][i] = None
|
||||
except IndexError:
|
||||
# No data in range. Just do nothing.
|
||||
pass
|
||||
data.append(full_copy)
|
||||
else:
|
||||
data.append(full)
|
||||
|
||||
full_copy = deepcopy(full)
|
||||
full_copy['comments'] = []
|
||||
data.append(full_copy)
|
||||
return data
|
||||
|
||||
|
||||
@ -123,6 +101,43 @@ class MotionChangeRecommendationAccessPermissions(BaseAccessPermissions):
|
||||
return MotionChangeRecommendationSerializer
|
||||
|
||||
|
||||
class MotionCommentSectionAccessPermissions(BaseAccessPermissions):
|
||||
"""
|
||||
Access permissions container for MotionCommentSection and MotionCommentSectionViewSet.
|
||||
"""
|
||||
def check_permissions(self, user):
|
||||
"""
|
||||
Returns True if the user has read access model instances.
|
||||
"""
|
||||
return has_perm(user, 'motions.can_see')
|
||||
|
||||
def get_serializer_class(self, user=None):
|
||||
"""
|
||||
Returns serializer class.
|
||||
"""
|
||||
from .serializers import MotionCommentSectionSerializer
|
||||
|
||||
return MotionCommentSectionSerializer
|
||||
|
||||
def get_restricted_data(
|
||||
self,
|
||||
full_data: List[Dict[str, Any]],
|
||||
user: Optional[CollectionElement]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
If the user has manage rights, he can see all sections. If not all sections
|
||||
will be removed, when the user is not in at least one of the read_groups.
|
||||
"""
|
||||
data: List[Dict[str, Any]] = []
|
||||
if has_perm(user, 'motions.can_manage'):
|
||||
data = full_data
|
||||
else:
|
||||
for full in full_data:
|
||||
read_groups = full.get('read_groups_id', [])
|
||||
if in_some_groups(user, read_groups):
|
||||
data.append(full)
|
||||
return data
|
||||
|
||||
|
||||
class CategoryAccessPermissions(BaseAccessPermissions):
|
||||
"""
|
||||
Access permissions container for Category and CategoryViewSet.
|
||||
|
@ -25,6 +25,7 @@ class MotionsAppConfig(AppConfig):
|
||||
from .views import (
|
||||
CategoryViewSet,
|
||||
MotionViewSet,
|
||||
MotionCommentSectionViewSet,
|
||||
MotionBlockViewSet,
|
||||
MotionPollViewSet,
|
||||
MotionChangeRecommendationViewSet,
|
||||
@ -51,6 +52,7 @@ class MotionsAppConfig(AppConfig):
|
||||
router.register(self.get_model('Category').get_collection_string(), CategoryViewSet)
|
||||
router.register(self.get_model('Motion').get_collection_string(), MotionViewSet)
|
||||
router.register(self.get_model('MotionBlock').get_collection_string(), MotionBlockViewSet)
|
||||
router.register(self.get_model('MotionCommentSection').get_collection_string(), MotionCommentSectionViewSet)
|
||||
router.register(self.get_model('Workflow').get_collection_string(), WorkflowViewSet)
|
||||
router.register(self.get_model('MotionChangeRecommendation').get_collection_string(),
|
||||
MotionChangeRecommendationViewSet)
|
||||
@ -62,5 +64,6 @@ class MotionsAppConfig(AppConfig):
|
||||
Yields all Cachables required on startup i. e. opening the websocket
|
||||
connection.
|
||||
"""
|
||||
for model_name in ('Category', 'Motion', 'MotionBlock', 'Workflow', 'MotionChangeRecommendation'):
|
||||
for model_name in ('Category', 'Motion', 'MotionBlock', 'Workflow',
|
||||
'MotionChangeRecommendation', 'MotionCommentSection'):
|
||||
yield self.get_model(model_name)
|
||||
|
@ -106,15 +106,6 @@ def get_config_variables():
|
||||
group='Motions',
|
||||
subgroup='General')
|
||||
|
||||
yield ConfigVariable(
|
||||
name='motions_allow_disable_versioning',
|
||||
default_value=False,
|
||||
input_type='boolean',
|
||||
label='Allow to disable versioning',
|
||||
weight=329,
|
||||
group='Motions',
|
||||
subgroup='General')
|
||||
|
||||
yield ConfigVariable(
|
||||
name='motions_stop_submitting',
|
||||
default_value=False,
|
||||
@ -210,17 +201,6 @@ def get_config_variables():
|
||||
group='Motions',
|
||||
subgroup='Supporters')
|
||||
|
||||
# Comments
|
||||
|
||||
yield ConfigVariable(
|
||||
name='motions_comments',
|
||||
default_value={},
|
||||
input_type='comments',
|
||||
label='Comment fields for motions',
|
||||
weight=353,
|
||||
group='Motions',
|
||||
subgroup='Comments')
|
||||
|
||||
# Voting and ballot papers
|
||||
|
||||
yield ConfigVariable(
|
||||
|
131
openslides/motions/migrations/0011_motion_version.py
Normal file
131
openslides/motions/migrations/0011_motion_version.py
Normal file
@ -0,0 +1,131 @@
|
||||
# Generated by Django 2.1 on 2018-08-31 13:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfield.encoder
|
||||
import jsonfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_motion_version_content_to_motion(apps, schema_editor):
|
||||
"""
|
||||
Move all motion version content of the active version to the motion.
|
||||
"""
|
||||
Motion = apps.get_model('motions', 'Motion')
|
||||
|
||||
for motion in Motion.objects.all():
|
||||
motion.title = motion.active_version.title
|
||||
motion.text = motion.active_version.text
|
||||
motion.reason = motion.active_version.reason
|
||||
motion.modified_final_version = motion.active_version.modified_final_version
|
||||
motion.amendment_paragraphs = motion.active_version.amendment_paragraphs
|
||||
motion.save(skip_autoupdate=True)
|
||||
|
||||
|
||||
def migrate_active_change_recommendations(apps, schema_editor):
|
||||
"""
|
||||
Delete all change recommendation of motion versions, that are not active. For active
|
||||
change recommendations the motion id will be set.
|
||||
"""
|
||||
MotionChangeRecommendation = apps.get_model('motions', 'MotionChangeRecommendation')
|
||||
to_delete = []
|
||||
for cr in MotionChangeRecommendation.objects.all():
|
||||
# chack if version id matches the active version of the motion
|
||||
if cr.motion_version.id == cr.motion_version.motion.active_version.id:
|
||||
cr.motion = cr.motion_version.motion
|
||||
cr.save(skip_autoupdate=True)
|
||||
else:
|
||||
to_delete.append(cr)
|
||||
|
||||
# delete non active change recommendations
|
||||
for cr in to_delete:
|
||||
cr.delete(skip_autoupdate=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('motions', '0010_auto_20180822_1042'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Create new fields. Title and Text have empty defaults, but the values
|
||||
# should be overwritten by copy_motion_version_content_to_motion. In the next
|
||||
# migration file these defaults are removed.
|
||||
migrations.AddField(
|
||||
model_name='motion',
|
||||
name='title',
|
||||
field=models.CharField(max_length=255, default=''),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='motion',
|
||||
name='text',
|
||||
field=models.TextField(default=''),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='motion',
|
||||
name='reason',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='motion',
|
||||
name='modified_final_version',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='motion',
|
||||
name='amendment_paragraphs',
|
||||
field=jsonfield.fields.JSONField(
|
||||
dump_kwargs={
|
||||
'cls': jsonfield.encoder.JSONEncoder,
|
||||
'separators': (',', ':')
|
||||
},
|
||||
load_kwargs={},
|
||||
null=True),
|
||||
),
|
||||
|
||||
# Copy old motion version data
|
||||
migrations.RunPython(copy_motion_version_content_to_motion),
|
||||
|
||||
# Change recommendations
|
||||
migrations.AddField(
|
||||
model_name='motionchangerecommendation',
|
||||
name='motion',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
null=True, # This is reverted in the next migration
|
||||
related_name='change_recommendations',
|
||||
to='motions.Motion'),
|
||||
),
|
||||
migrations.RunPython(migrate_active_change_recommendations),
|
||||
migrations.RemoveField(
|
||||
model_name='motionchangerecommendation',
|
||||
name='motion_version',
|
||||
),
|
||||
|
||||
# remove motion version references from motion and state.
|
||||
migrations.RemoveField(
|
||||
model_name='motion',
|
||||
name='active_version',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='motionversion',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='motionversion',
|
||||
name='motion',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='state',
|
||||
name='leave_old_version_active',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='state',
|
||||
name='versioning',
|
||||
),
|
||||
|
||||
# Delete motion version.
|
||||
migrations.DeleteModel(
|
||||
name='MotionVersion',
|
||||
),
|
||||
]
|
218
openslides/motions/migrations/0012_motion_comments.py
Normal file
218
openslides/motions/migrations/0012_motion_comments.py
Normal file
@ -0,0 +1,218 @@
|
||||
# Generated by Django 2.1 on 2018-08-31 13:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import migrations, models
|
||||
|
||||
import openslides
|
||||
|
||||
|
||||
def create_comment_sections_from_config_and_move_comments_to_own_model(apps, schema_editor):
|
||||
ConfigStore = apps.get_model('core', 'ConfigStore')
|
||||
Motion = apps.get_model('motions', 'Motion')
|
||||
MotionComment = apps.get_model('motions', 'MotionComment')
|
||||
MotionCommentSection = apps.get_model('motions', 'MotionCommentSection')
|
||||
Group = apps.get_model(settings.AUTH_GROUP_MODEL)
|
||||
|
||||
# try to get old motions_comments config variable, where all comment fields are saved
|
||||
try:
|
||||
motions_comments = ConfigStore.objects.get(key='motions_comments')
|
||||
except ConfigStore.DoesNotExist:
|
||||
return
|
||||
comments_sections = motions_comments.value
|
||||
|
||||
# Delete config value
|
||||
motions_comments.delete()
|
||||
|
||||
# Get can_see_comments and can_manage_comments permissions and the associated groups
|
||||
can_see_comments = Permission.objects.filter(codename='can_see_comments')
|
||||
if len(can_see_comments) == 1:
|
||||
# Save groups. list() is necessary to evaluate the database query right now.
|
||||
can_see_groups = list(can_see_comments.get().group_set.all())
|
||||
else:
|
||||
can_see_groups = Group.objects.all()
|
||||
|
||||
can_manage_comments = Permission.objects.filter(codename='can_manage_comments')
|
||||
if len(can_manage_comments) == 1:
|
||||
# Save groups. list() is necessary to evaluate the database query right now.
|
||||
can_manage_groups = list(can_manage_comments.get().group_set.all())
|
||||
else:
|
||||
can_manage_groups = Group.objects.all()
|
||||
|
||||
# Create comment sections. Map them to the old ids, so we can find the right section
|
||||
# when creating actual comments
|
||||
old_id_mapping = {}
|
||||
# Keep track of the special comment sections "forState" and "forRecommendation". If a
|
||||
# comment is found, the comment value will be assigned to new motion fields and not comments.
|
||||
forStateId = None
|
||||
forRecommendationId = None
|
||||
for id, section in comments_sections.items():
|
||||
if section is None:
|
||||
continue
|
||||
if section.get('forState', False):
|
||||
forStateId = id
|
||||
elif section.get('forRecommendation', False):
|
||||
forRecommendationId = id
|
||||
else:
|
||||
comment_section = MotionCommentSection(name=section['name'])
|
||||
comment_section.save(skip_autoupdate=True)
|
||||
comment_section.read_groups.add(*[group.id for group in can_see_groups])
|
||||
comment_section.write_groups.add(*[group.id for group in can_manage_groups])
|
||||
old_id_mapping[id] = comment_section
|
||||
|
||||
# Create all comments objects
|
||||
comments = []
|
||||
for motion in Motion.objects.all():
|
||||
if not isinstance(motion.comments, dict):
|
||||
continue
|
||||
|
||||
for section_id, comment_value in motion.comments.items():
|
||||
# Skip empty sections.
|
||||
comment_value = comment_value.strip()
|
||||
if comment_value == '':
|
||||
continue
|
||||
# Special comments will be moved to separate fields.
|
||||
if section_id == forStateId:
|
||||
motion.state_extension = comment_value
|
||||
motion.save(skip_autoupdate=True)
|
||||
elif section_id == forRecommendationId:
|
||||
motion.recommendation_extension = comment_value
|
||||
motion.save(skip_autoupdate=True)
|
||||
else:
|
||||
comment = MotionComment(
|
||||
comment=comment_value,
|
||||
motion=motion,
|
||||
section=old_id_mapping[section_id])
|
||||
comments.append(comment)
|
||||
MotionComment.objects.bulk_create(comments)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0006_user_email'),
|
||||
('motions', '0011_motion_version'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Cleanup from last migration. Somehow cannot be done there.
|
||||
migrations.AlterField( # remove default=''
|
||||
model_name='motion',
|
||||
name='text',
|
||||
field=models.TextField(),
|
||||
),
|
||||
migrations.AlterField( # remove default=''
|
||||
model_name='motion',
|
||||
name='title',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField( # remove null=True
|
||||
model_name='motionchangerecommendation',
|
||||
name='motion',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='change_recommendations',
|
||||
to='motions.Motion'),
|
||||
),
|
||||
|
||||
# Add extension fields for former "special comments". No hack anymore..
|
||||
migrations.AddField(
|
||||
model_name='motion',
|
||||
name='recommendation_extension',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='motion',
|
||||
name='state_extension',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
|
||||
migrations.AlterModelOptions(
|
||||
name='motion',
|
||||
options={
|
||||
'default_permissions': (),
|
||||
'ordering': ('identifier',),
|
||||
'permissions': (
|
||||
('can_see', 'Can see motions'),
|
||||
('can_create', 'Can create motions'),
|
||||
('can_support', 'Can support motions'),
|
||||
('can_manage', 'Can manage motions')),
|
||||
'verbose_name': 'Motion'},
|
||||
),
|
||||
# Comments and CommentsSection models
|
||||
migrations.CreateModel(
|
||||
name='MotionComment',
|
||||
fields=[
|
||||
('id', models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID')),
|
||||
('comment', models.TextField()),
|
||||
],
|
||||
options={
|
||||
'default_permissions': (),
|
||||
},
|
||||
bases=(openslides.utils.models.RESTModelMixin, models.Model), # type: ignore
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MotionCommentSection',
|
||||
fields=[
|
||||
('id', models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('read_groups', models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name='read_comments',
|
||||
to=settings.AUTH_GROUP_MODEL)),
|
||||
('write_groups', models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name='write_comments',
|
||||
to=settings.AUTH_GROUP_MODEL)),
|
||||
],
|
||||
options={
|
||||
'default_permissions': (),
|
||||
},
|
||||
bases=(openslides.utils.models.RESTModelMixin, models.Model), # type: ignore
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='motioncomment',
|
||||
name='section',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='comments',
|
||||
to='motions.MotionCommentSection'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='motioncomment',
|
||||
name='motion',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='motions.Motion'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='motioncomment',
|
||||
unique_together={('motion', 'section')},
|
||||
),
|
||||
|
||||
# Move the comments and sections
|
||||
migrations.RunPython(create_comment_sections_from_config_and_move_comments_to_own_model),
|
||||
|
||||
# Remove old comment field from motion, use the new model instead
|
||||
migrations.RemoveField(
|
||||
model_name='motion',
|
||||
name='comments',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='motioncomment',
|
||||
name='motion',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='comments',
|
||||
to='motions.Motion'),
|
||||
),
|
||||
]
|
@ -7,11 +7,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import formats, timezone
|
||||
from django.utils.translation import (
|
||||
ugettext as _,
|
||||
ugettext_lazy,
|
||||
ugettext_noop,
|
||||
)
|
||||
from django.utils.translation import ugettext as _, ugettext_noop
|
||||
from jsonfield import JSONField
|
||||
|
||||
from openslides.agenda.models import Item
|
||||
@ -33,6 +29,7 @@ from .access_permissions import (
|
||||
MotionAccessPermissions,
|
||||
MotionBlockAccessPermissions,
|
||||
MotionChangeRecommendationAccessPermissions,
|
||||
MotionCommentSectionAccessPermissions,
|
||||
WorkflowAccessPermissions,
|
||||
)
|
||||
from .exceptions import WorkflowError
|
||||
@ -48,10 +45,12 @@ class MotionManager(models.Manager):
|
||||
join and prefetch all related models.
|
||||
"""
|
||||
return (self.get_queryset()
|
||||
.select_related('active_version', 'state')
|
||||
.select_related('state')
|
||||
.prefetch_related(
|
||||
'state__workflow',
|
||||
'versions',
|
||||
'comments',
|
||||
'comments__section',
|
||||
'comments__section__read_groups',
|
||||
'agenda_items',
|
||||
'log_messages',
|
||||
'polls',
|
||||
@ -71,18 +70,26 @@ class Motion(RESTModelMixin, models.Model):
|
||||
|
||||
objects = MotionManager()
|
||||
|
||||
active_version = models.ForeignKey(
|
||||
'MotionVersion',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="active_version")
|
||||
"""
|
||||
Points to a specific version.
|
||||
title = models.CharField(max_length=255)
|
||||
"""The title of a motion."""
|
||||
|
||||
Used be the permitted-version-system to deside which version is the active
|
||||
version. Could also be used to only choose a specific version as a default
|
||||
version. Like the sighted versions on Wikipedia.
|
||||
text = models.TextField()
|
||||
"""The text of a motion."""
|
||||
|
||||
amendment_paragraphs = JSONField(null=True)
|
||||
"""
|
||||
If paragraph-based, diff-enabled amendment style is used, this field stores an array of strings or null values.
|
||||
Each entry corresponds to a paragraph of the text of the original motion.
|
||||
If the entry is null, then the paragraph remains unchanged.
|
||||
If the entry is a string, this is the new text of the paragraph.
|
||||
amendment_paragraphs and text are mutually exclusive.
|
||||
"""
|
||||
|
||||
modified_final_version = models.TextField(null=True, blank=True)
|
||||
"""A field to copy in the final version of the motion and edit it there."""
|
||||
|
||||
reason = models.TextField(null=True, blank=True)
|
||||
"""The reason for a motion."""
|
||||
|
||||
state = models.ForeignKey(
|
||||
'State',
|
||||
@ -95,6 +102,11 @@ class Motion(RESTModelMixin, models.Model):
|
||||
This attribute is to get the current state of the motion.
|
||||
"""
|
||||
|
||||
state_extension = models.TextField(blank=True, null=True)
|
||||
"""
|
||||
A text field fo additional information about the state.
|
||||
"""
|
||||
|
||||
recommendation = models.ForeignKey(
|
||||
'State',
|
||||
related_name='+',
|
||||
@ -104,6 +116,11 @@ class Motion(RESTModelMixin, models.Model):
|
||||
The recommendation of a person or committee for this motion.
|
||||
"""
|
||||
|
||||
recommendation_extension = models.TextField(blank=True, null=True)
|
||||
"""
|
||||
A text field fo additional information about the recommendation.
|
||||
"""
|
||||
|
||||
identifier = models.CharField(max_length=255, null=True, blank=True,
|
||||
unique=True)
|
||||
"""
|
||||
@ -168,11 +185,6 @@ class Motion(RESTModelMixin, models.Model):
|
||||
Users who support this motion.
|
||||
"""
|
||||
|
||||
comments = JSONField(null=True)
|
||||
"""
|
||||
Configurable fields for comments.
|
||||
"""
|
||||
|
||||
# In theory there could be one then more agenda_item. But we support only
|
||||
# one. See the property agenda_item.
|
||||
agenda_items = GenericRelation(Item, related_name='motions')
|
||||
@ -183,8 +195,6 @@ class Motion(RESTModelMixin, models.Model):
|
||||
('can_see', 'Can see motions'),
|
||||
('can_create', 'Can create motions'),
|
||||
('can_support', 'Can support motions'),
|
||||
('can_see_comments', 'Can see comments'),
|
||||
('can_manage_comments', 'Can manage comments'),
|
||||
('can_manage', 'Can manage motions'),
|
||||
)
|
||||
ordering = ('identifier', )
|
||||
@ -197,34 +207,13 @@ class Motion(RESTModelMixin, models.Model):
|
||||
return self.title
|
||||
|
||||
# TODO: Use transaction
|
||||
def save(self, use_version=None, skip_autoupdate=False, *args, **kwargs):
|
||||
def save(self, skip_autoupdate=False, *args, **kwargs):
|
||||
"""
|
||||
Save the motion.
|
||||
|
||||
1. Set the state of a new motion to the default state.
|
||||
2. Ensure that the identifier is not an empty string.
|
||||
3. Save the motion object.
|
||||
4. Save the version data.
|
||||
5. Set the active version for the motion if a new version object was saved.
|
||||
|
||||
The version data is *not* saved, if
|
||||
1. the django-feature 'update_fields' is used or
|
||||
2. the argument use_version is False (differ to None).
|
||||
|
||||
The argument use_version is choose the version object into which the
|
||||
version data is saved.
|
||||
* If use_version is False, no version data is saved.
|
||||
* If use_version is None, the last version is used.
|
||||
* Else the given version is used.
|
||||
|
||||
To create and use a new version object, you have to set it via the
|
||||
use_version argument. You have to set the title, text/amendment_paragraphs,
|
||||
modified final version and reason into this version object before giving it
|
||||
to this save method. The properties motion.title, motion.text,
|
||||
motion.amendment_paragraphs, motion.modified_final_version and motion.reason will be ignored.
|
||||
|
||||
text and amendment_paragraphs are mutually exclusive; if both are given,
|
||||
amendment_paragraphs takes precedence.
|
||||
"""
|
||||
if not self.state:
|
||||
self.reset_state()
|
||||
@ -256,55 +245,6 @@ class Motion(RESTModelMixin, models.Model):
|
||||
# Save was successful. End loop.
|
||||
break
|
||||
|
||||
if 'update_fields' in kwargs:
|
||||
# Do not save the version data if only some motion fields are updated.
|
||||
if not skip_autoupdate:
|
||||
inform_changed_data(self)
|
||||
return
|
||||
|
||||
if use_version is False:
|
||||
# We do not need to save the version.
|
||||
if not skip_autoupdate:
|
||||
inform_changed_data(self)
|
||||
return
|
||||
elif use_version is None:
|
||||
use_version = self.get_last_version()
|
||||
# Save title, text, amendment paragraphs, modified final version and reason into the version object.
|
||||
for attr in ['title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason']:
|
||||
_attr = '_%s' % attr
|
||||
data = getattr(self, _attr, None)
|
||||
if data is not None:
|
||||
setattr(use_version, attr, data)
|
||||
delattr(self, _attr)
|
||||
|
||||
# If version is not in the database, test if it has new data and set
|
||||
# the version_number.
|
||||
if use_version.id is None:
|
||||
if not self.version_data_changed(use_version):
|
||||
# We do not need to save the version.
|
||||
if not skip_autoupdate:
|
||||
inform_changed_data(self)
|
||||
return
|
||||
version_number = self.versions.aggregate(Max('version_number'))['version_number__max'] or 0
|
||||
use_version.version_number = version_number + 1
|
||||
|
||||
# Necessary line if the version was set before the motion got an id.
|
||||
use_version.motion = use_version.motion
|
||||
|
||||
# Always skip autoupdate. Maybe we run it later in this method.
|
||||
use_version.save(skip_autoupdate=True)
|
||||
|
||||
# Set the active version of this motion. This has to be done after the
|
||||
# version is saved in the database.
|
||||
# TODO: Move parts of these last lines of code outside the save method
|
||||
# when other versions than the last one should be edited later on.
|
||||
if self.active_version is None or not self.state.leave_old_version_active:
|
||||
# TODO: Don't call this if it was not a new version
|
||||
self.active_version = use_version
|
||||
# Always skip autoupdate. Maybe we run it later in this method.
|
||||
self.save(update_fields=['active_version'], skip_autoupdate=True)
|
||||
|
||||
# Finally run autoupdate if it is not skipped by caller.
|
||||
if not skip_autoupdate:
|
||||
inform_changed_data(self)
|
||||
|
||||
@ -319,24 +259,6 @@ class Motion(RESTModelMixin, models.Model):
|
||||
id=self.pk)
|
||||
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore
|
||||
|
||||
def version_data_changed(self, version):
|
||||
"""
|
||||
Compare the version with the last version of the motion.
|
||||
|
||||
Returns True if the version data (title, text, amendment_paragraphs,
|
||||
modified_final_version, reason) is different,
|
||||
else returns False.
|
||||
"""
|
||||
if not self.versions.exists():
|
||||
# If there is no version in the database, the data has always changed.
|
||||
return True
|
||||
|
||||
last_version = self.get_last_version()
|
||||
for attr in ['title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason']:
|
||||
if getattr(last_version, attr) != getattr(version, attr):
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_identifier(self):
|
||||
"""
|
||||
Sets the motion identifier automaticly according to the config value if
|
||||
@ -421,188 +343,6 @@ class Motion(RESTModelMixin, models.Model):
|
||||
result = '0' * (settings.MOTION_IDENTIFIER_MIN_DIGITS - len(str(number))) + result
|
||||
return result
|
||||
|
||||
def get_title(self):
|
||||
"""
|
||||
Get the title of the motion.
|
||||
|
||||
The title is taken from motion.version.
|
||||
"""
|
||||
try:
|
||||
return self._title
|
||||
except AttributeError:
|
||||
return self.get_active_version().title
|
||||
|
||||
def set_title(self, title):
|
||||
"""
|
||||
Set the title of the motion.
|
||||
|
||||
The title will be saved in the version object, when motion.save() is
|
||||
called.
|
||||
"""
|
||||
self._title = title
|
||||
|
||||
title = property(get_title, set_title)
|
||||
"""
|
||||
The title of the motion.
|
||||
|
||||
Is saved in a MotionVersion object.
|
||||
"""
|
||||
|
||||
def get_text(self):
|
||||
"""
|
||||
Get the text of the motion.
|
||||
|
||||
Simular to get_title().
|
||||
"""
|
||||
try:
|
||||
return self._text
|
||||
except AttributeError:
|
||||
return self.get_active_version().text
|
||||
|
||||
def set_text(self, text):
|
||||
"""
|
||||
Set the text of the motion.
|
||||
|
||||
Simular to set_title().
|
||||
"""
|
||||
self._text = text
|
||||
|
||||
text = property(get_text, set_text)
|
||||
"""
|
||||
The text of a motion.
|
||||
|
||||
Is saved in a MotionVersion object.
|
||||
"""
|
||||
|
||||
def get_amendment_paragraphs(self):
|
||||
"""
|
||||
Get the paragraphs of the amendment.
|
||||
Returns an array of entries that are either null (paragraph is not changed)
|
||||
or a string (the new version of this paragraph).
|
||||
"""
|
||||
try:
|
||||
return self._amendment_paragraphs
|
||||
except AttributeError:
|
||||
return self.get_active_version().amendment_paragraphs
|
||||
|
||||
def set_amendment_paragraphs(self, text):
|
||||
"""
|
||||
Set the paragraphs of the amendment.
|
||||
Has to be an array of entries that are either null (paragraph is not changed)
|
||||
or a string (the new version of this paragraph).
|
||||
"""
|
||||
self._amendment_paragraphs = text
|
||||
|
||||
amendment_paragraphs = property(get_amendment_paragraphs, set_amendment_paragraphs)
|
||||
"""
|
||||
The paragraphs of the amendment.
|
||||
|
||||
Is saved in a MotionVersion object.
|
||||
"""
|
||||
|
||||
def get_modified_final_version(self):
|
||||
"""
|
||||
Get the modified_final_version of the motion.
|
||||
|
||||
Simular to get_title().
|
||||
"""
|
||||
try:
|
||||
return self._modified_final_version
|
||||
except AttributeError:
|
||||
return self.get_active_version().modified_final_version
|
||||
|
||||
def set_modified_final_version(self, modified_final_version):
|
||||
"""
|
||||
Set the modified_final_version of the motion.
|
||||
|
||||
Simular to set_title().
|
||||
"""
|
||||
self._modified_final_version = modified_final_version
|
||||
|
||||
modified_final_version = property(get_modified_final_version, set_modified_final_version)
|
||||
"""
|
||||
The modified_final_version for the motion.
|
||||
|
||||
Is saved in a MotionVersion object.
|
||||
"""
|
||||
|
||||
def get_reason(self):
|
||||
"""
|
||||
Get the reason of the motion.
|
||||
|
||||
Simular to get_title().
|
||||
"""
|
||||
try:
|
||||
return self._reason
|
||||
except AttributeError:
|
||||
return self.get_active_version().reason
|
||||
|
||||
def set_reason(self, reason):
|
||||
"""
|
||||
Set the reason of the motion.
|
||||
|
||||
Simular to set_title().
|
||||
"""
|
||||
self._reason = reason
|
||||
|
||||
reason = property(get_reason, set_reason)
|
||||
"""
|
||||
The reason for the motion.
|
||||
|
||||
Is saved in a MotionVersion object.
|
||||
"""
|
||||
|
||||
def get_new_version(self, **kwargs):
|
||||
"""
|
||||
Return a version object, not saved in the database.
|
||||
|
||||
The version data of the new version object is populated with the data
|
||||
set via motion.title, motion.text, motion.amendment_paragraphs,
|
||||
motion.modified_final_version and motion.reason if these data are not
|
||||
given as keyword arguments. If the data is not set in the motion
|
||||
attributes, it is populated with the data from the last version
|
||||
object if such object exists.
|
||||
"""
|
||||
if self.pk is None:
|
||||
# Do not reference the MotionVersion object to an unsaved motion
|
||||
new_version = MotionVersion(**kwargs)
|
||||
else:
|
||||
new_version = MotionVersion(motion=self, **kwargs)
|
||||
if self.versions.exists():
|
||||
last_version = self.get_last_version()
|
||||
else:
|
||||
last_version = None
|
||||
for attr in ['title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason']:
|
||||
if attr in kwargs:
|
||||
continue
|
||||
_attr = '_%s' % attr
|
||||
data = getattr(self, _attr, None)
|
||||
if data is None and last_version is not None:
|
||||
data = getattr(last_version, attr)
|
||||
if data is not None:
|
||||
setattr(new_version, attr, data)
|
||||
return new_version
|
||||
|
||||
def get_active_version(self):
|
||||
"""
|
||||
Returns the active version of the motion.
|
||||
|
||||
If no active version is set by now, the last_version is used.
|
||||
"""
|
||||
if self.active_version:
|
||||
return self.active_version
|
||||
else:
|
||||
return self.get_last_version()
|
||||
|
||||
def get_last_version(self):
|
||||
"""
|
||||
Return the newest version of the motion.
|
||||
"""
|
||||
try:
|
||||
return self.versions.order_by('-version_number')[0]
|
||||
except IndexError:
|
||||
return self.get_new_version()
|
||||
|
||||
def is_submitter(self, user):
|
||||
"""
|
||||
Returns True if user is a submitter of this motion, else False.
|
||||
@ -777,6 +517,76 @@ class Motion(RESTModelMixin, models.Model):
|
||||
return list(filter(lambda amend: amend.is_paragraph_based_amendment(), self.amendments.all()))
|
||||
|
||||
|
||||
class MotionCommentSection(RESTModelMixin, models.Model):
|
||||
"""
|
||||
The model for comment sections for motions. Each comment is related to one section, so
|
||||
each motions has the ability to have comments from the same section.
|
||||
"""
|
||||
access_permissions = MotionCommentSectionAccessPermissions()
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
"""
|
||||
The name of the section.
|
||||
"""
|
||||
|
||||
read_groups = models.ManyToManyField(
|
||||
settings.AUTH_GROUP_MODEL,
|
||||
blank=True,
|
||||
related_name='read_comments')
|
||||
"""
|
||||
These groups have read-access to the section.
|
||||
"""
|
||||
|
||||
write_groups = models.ManyToManyField(
|
||||
settings.AUTH_GROUP_MODEL,
|
||||
blank=True,
|
||||
related_name='write_comments')
|
||||
"""
|
||||
These groups have write-access to the section.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
|
||||
|
||||
class MotionComment(RESTModelMixin, models.Model):
|
||||
"""
|
||||
Represents a motion comment. A comment is always related to a motion and a comment
|
||||
section. The section determinates the title of the category.
|
||||
"""
|
||||
|
||||
comment = models.TextField()
|
||||
"""
|
||||
The comment.
|
||||
"""
|
||||
|
||||
motion = models.ForeignKey(
|
||||
Motion,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='comments')
|
||||
"""
|
||||
The motion where this comment belongs to.
|
||||
"""
|
||||
|
||||
section = models.ForeignKey(
|
||||
MotionCommentSection,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='comments')
|
||||
"""
|
||||
The section of the comment.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
unique_together = ('motion', 'section')
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the motion to this instance which is the root REST element.
|
||||
"""
|
||||
return self.motion
|
||||
|
||||
|
||||
class SubmitterManager(models.Manager):
|
||||
"""
|
||||
Manager for Submitter model. Provides a customized add method.
|
||||
@ -840,68 +650,6 @@ class Submitter(RESTModelMixin, models.Model):
|
||||
return self.motion
|
||||
|
||||
|
||||
class MotionVersion(RESTModelMixin, models.Model):
|
||||
"""
|
||||
A MotionVersion object saves some date of the motion.
|
||||
"""
|
||||
|
||||
motion = models.ForeignKey(
|
||||
Motion,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='versions')
|
||||
"""The motion to which the version belongs."""
|
||||
|
||||
version_number = models.PositiveIntegerField(default=1)
|
||||
"""An id for this version in realation to a motion.
|
||||
|
||||
Is unique for each motion.
|
||||
"""
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
"""The title of a motion."""
|
||||
|
||||
text = models.TextField()
|
||||
"""The text of a motion."""
|
||||
|
||||
amendment_paragraphs = JSONField(null=True)
|
||||
"""
|
||||
If paragraph-based, diff-enabled amendment style is used, this field stores an array of strings or null values.
|
||||
Each entry corresponds to a paragraph of the text of the original motion.
|
||||
If the entry is null, then the paragraph remains unchanged.
|
||||
If the entry is a string, this is the new text of the paragraph.
|
||||
amendment_paragraphs and text are mutually exclusive.
|
||||
"""
|
||||
|
||||
modified_final_version = models.TextField(null=True, blank=True)
|
||||
"""A field to copy in the final version of the motion and edit it there."""
|
||||
|
||||
reason = models.TextField(null=True, blank=True)
|
||||
"""The reason for a motion."""
|
||||
|
||||
creation_time = models.DateTimeField(auto_now=True)
|
||||
"""Time when the version was saved."""
|
||||
|
||||
class Meta:
|
||||
default_permissions = ()
|
||||
unique_together = ("motion", "version_number")
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string, representing this object."""
|
||||
counter = self.version_number or ugettext_lazy('new')
|
||||
return "Motion %s, Version %s" % (self.motion_id, counter)
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
"""Return True, if the version is the active version of a motion. Else: False."""
|
||||
return self.active_version.exists()
|
||||
|
||||
def get_root_rest_element(self):
|
||||
"""
|
||||
Returns the motion to this instance which is the root REST element.
|
||||
"""
|
||||
return self.motion
|
||||
|
||||
|
||||
class MotionChangeRecommendationManager(models.Manager):
|
||||
"""
|
||||
Customized model manager to support our get_full_queryset method.
|
||||
@ -916,18 +664,18 @@ class MotionChangeRecommendationManager(models.Manager):
|
||||
|
||||
class MotionChangeRecommendation(RESTModelMixin, models.Model):
|
||||
"""
|
||||
A MotionChangeRecommendation object saves change recommendations for a specific MotionVersion
|
||||
A MotionChangeRecommendation object saves change recommendations for a specific Motion
|
||||
"""
|
||||
|
||||
access_permissions = MotionChangeRecommendationAccessPermissions()
|
||||
|
||||
objects = MotionChangeRecommendationManager()
|
||||
|
||||
motion_version = models.ForeignKey(
|
||||
MotionVersion,
|
||||
motion = models.ForeignKey(
|
||||
Motion,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='change_recommendations')
|
||||
"""The motion version to which the change recommendation belongs."""
|
||||
"""The motion to which the change recommendation belongs."""
|
||||
|
||||
rejected = models.BooleanField(default=False)
|
||||
"""If true, this change recommendation has been rejected"""
|
||||
@ -945,7 +693,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
|
||||
"""The number or the last affected line (inclusive)"""
|
||||
|
||||
text = models.TextField(blank=True)
|
||||
"""The replacement for the section of the original text specified by version, line_from and line_to"""
|
||||
"""The replacement for the section of the original text specified by motion, line_from and line_to"""
|
||||
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@ -966,7 +714,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
recommendations = (MotionChangeRecommendation.objects
|
||||
.filter(motion_version=self.motion_version)
|
||||
.filter(motion=self.motion)
|
||||
.exclude(pk=self.pk))
|
||||
|
||||
if self.collides_with_other_recommendation(recommendations):
|
||||
@ -980,7 +728,7 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string, representing this object."""
|
||||
return "Recommendation for Version %s, line %s - %s" % (self.motion_version_id, self.line_from, self.line_to)
|
||||
return "Recommendation for Motion %s, line %s - %s" % (self.motion_id, self.line_from, self.line_to)
|
||||
|
||||
|
||||
class Category(RESTModelMixin, models.Model):
|
||||
@ -1272,16 +1020,6 @@ class State(RESTModelMixin, models.Model):
|
||||
allow_submitter_edit = models.BooleanField(default=False)
|
||||
"""If true, the submitter can edit the motion in this state."""
|
||||
|
||||
versioning = models.BooleanField(default=False)
|
||||
"""
|
||||
If true, editing the motion will create a new version by default.
|
||||
This behavior can be changed by the form and view, e. g. via the
|
||||
MotionDisableVersioningMixin.
|
||||
"""
|
||||
|
||||
leave_old_version_active = models.BooleanField(default=False)
|
||||
"""If true, new versions are not automaticly set active."""
|
||||
|
||||
dont_set_identifier = models.BooleanField(default=False)
|
||||
"""
|
||||
Decides if the motion gets an identifier.
|
||||
|
@ -4,14 +4,15 @@ from django.db import transaction
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from ..poll.serializers import default_votes_validator
|
||||
from ..utils.auth import get_group_model
|
||||
from ..utils.rest_api import (
|
||||
CharField,
|
||||
DecimalField,
|
||||
DictField,
|
||||
Field,
|
||||
IdPrimaryKeyRelatedField,
|
||||
IntegerField,
|
||||
ModelSerializer,
|
||||
PrimaryKeyRelatedField,
|
||||
SerializerMethodField,
|
||||
ValidationError,
|
||||
)
|
||||
@ -21,9 +22,10 @@ from .models import (
|
||||
Motion,
|
||||
MotionBlock,
|
||||
MotionChangeRecommendation,
|
||||
MotionComment,
|
||||
MotionCommentSection,
|
||||
MotionLog,
|
||||
MotionPoll,
|
||||
MotionVersion,
|
||||
State,
|
||||
Submitter,
|
||||
Workflow,
|
||||
@ -88,8 +90,6 @@ class StateSerializer(ModelSerializer):
|
||||
'allow_support',
|
||||
'allow_create_poll',
|
||||
'allow_submitter_edit',
|
||||
'versioning',
|
||||
'leave_old_version_active',
|
||||
'dont_set_identifier',
|
||||
'show_state_extension_field',
|
||||
'show_recommendation_extension_field',
|
||||
@ -128,32 +128,6 @@ class WorkflowSerializer(ModelSerializer):
|
||||
return workflow
|
||||
|
||||
|
||||
class MotionCommentsJSONSerializerField(Field):
|
||||
"""
|
||||
Serializer for motions's comments JSONField.
|
||||
"""
|
||||
def to_representation(self, obj):
|
||||
"""
|
||||
Returns the value of the field.
|
||||
"""
|
||||
return obj
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
Checks that data is a list of strings.
|
||||
"""
|
||||
if type(data) is not dict:
|
||||
raise ValidationError({'detail': 'Data must be a dict.'})
|
||||
for id, comment in data.items():
|
||||
try:
|
||||
id = int(id)
|
||||
except ValueError:
|
||||
raise ValidationError({'detail': 'Id must be an int.'})
|
||||
if type(comment) is not str:
|
||||
raise ValidationError({'detail': 'Comment must be a string.'})
|
||||
return data
|
||||
|
||||
|
||||
class AmendmentParagraphsJSONSerializerField(Field):
|
||||
"""
|
||||
Serializer for motions's amendment_paragraphs JSONField.
|
||||
@ -291,25 +265,6 @@ class MotionPollSerializer(ModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class MotionVersionSerializer(ModelSerializer):
|
||||
amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False)
|
||||
|
||||
"""
|
||||
Serializer for motion.models.MotionVersion objects.
|
||||
"""
|
||||
class Meta:
|
||||
model = MotionVersion
|
||||
fields = (
|
||||
'id',
|
||||
'version_number',
|
||||
'creation_time',
|
||||
'title',
|
||||
'text',
|
||||
'amendment_paragraphs',
|
||||
'modified_final_version',
|
||||
'reason',)
|
||||
|
||||
|
||||
class MotionChangeRecommendationSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for motion.models.MotionChangeRecommendation objects.
|
||||
@ -318,7 +273,7 @@ class MotionChangeRecommendationSerializer(ModelSerializer):
|
||||
model = MotionChangeRecommendation
|
||||
fields = (
|
||||
'id',
|
||||
'motion_version',
|
||||
'motion',
|
||||
'rejected',
|
||||
'type',
|
||||
'other_description',
|
||||
@ -337,6 +292,47 @@ class MotionChangeRecommendationSerializer(ModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class MotionCommentSectionSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for motion.models.MotionCommentSection objects.
|
||||
"""
|
||||
read_groups = IdPrimaryKeyRelatedField(
|
||||
many=True,
|
||||
required=False,
|
||||
queryset=get_group_model().objects.all())
|
||||
|
||||
write_groups = IdPrimaryKeyRelatedField(
|
||||
many=True,
|
||||
required=False,
|
||||
queryset=get_group_model().objects.all())
|
||||
|
||||
class Meta:
|
||||
model = MotionCommentSection
|
||||
fields = (
|
||||
'id',
|
||||
'name',
|
||||
'read_groups',
|
||||
'write_groups',)
|
||||
|
||||
|
||||
class MotionCommentSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for motion.models.MotionComment objects.
|
||||
"""
|
||||
read_groups_id = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MotionComment
|
||||
fields = (
|
||||
'id',
|
||||
'comment',
|
||||
'section',
|
||||
'read_groups_id',)
|
||||
|
||||
def get_read_groups_id(self, comment):
|
||||
return [group.id for group in comment.section.read_groups.all()]
|
||||
|
||||
|
||||
class SubmitterSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for motion.models.Submitter objects.
|
||||
@ -355,17 +351,15 @@ class MotionSerializer(ModelSerializer):
|
||||
"""
|
||||
Serializer for motion.models.Motion objects.
|
||||
"""
|
||||
active_version = PrimaryKeyRelatedField(read_only=True)
|
||||
comments = MotionCommentsJSONSerializerField(required=False)
|
||||
comments = MotionCommentSerializer(many=True, read_only=True)
|
||||
log_messages = MotionLogSerializer(many=True, read_only=True)
|
||||
polls = MotionPollSerializer(many=True, read_only=True)
|
||||
modified_final_version = CharField(allow_blank=True, required=False, write_only=True)
|
||||
reason = CharField(allow_blank=True, required=False, write_only=True)
|
||||
modified_final_version = CharField(allow_blank=True, required=False)
|
||||
reason = CharField(allow_blank=True, required=False)
|
||||
state_required_permission_to_see = SerializerMethodField()
|
||||
text = CharField(write_only=True, allow_blank=True)
|
||||
title = CharField(max_length=255, write_only=True)
|
||||
amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False, write_only=True)
|
||||
versions = MotionVersionSerializer(many=True, read_only=True)
|
||||
text = CharField(allow_blank=True)
|
||||
title = CharField(max_length=255)
|
||||
amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False)
|
||||
workflow_id = IntegerField(
|
||||
min_value=1,
|
||||
required=False,
|
||||
@ -384,19 +378,19 @@ class MotionSerializer(ModelSerializer):
|
||||
'amendment_paragraphs',
|
||||
'modified_final_version',
|
||||
'reason',
|
||||
'versions',
|
||||
'active_version',
|
||||
'parent',
|
||||
'category',
|
||||
'comments',
|
||||
'motion_block',
|
||||
'origin',
|
||||
'submitters',
|
||||
'supporters',
|
||||
'comments',
|
||||
'state',
|
||||
'state_extension',
|
||||
'state_required_permission_to_see',
|
||||
'workflow_id',
|
||||
'recommendation',
|
||||
'recommendation_extension',
|
||||
'tags',
|
||||
'attachments',
|
||||
'polls',
|
||||
@ -416,11 +410,6 @@ class MotionSerializer(ModelSerializer):
|
||||
if 'reason' in data:
|
||||
data['reason'] = validate_html(data['reason'])
|
||||
|
||||
validated_comments = dict()
|
||||
for id, comment in data.get('comments', {}).items():
|
||||
validated_comments[id] = validate_html(comment)
|
||||
data['comments'] = validated_comments
|
||||
|
||||
if 'amendment_paragraphs' in data:
|
||||
data['amendment_paragraphs'] = list(map(lambda entry: validate_html(entry) if type(entry) is str else None,
|
||||
data['amendment_paragraphs']))
|
||||
@ -451,7 +440,6 @@ class MotionSerializer(ModelSerializer):
|
||||
motion.category = validated_data.get('category')
|
||||
motion.motion_block = validated_data.get('motion_block')
|
||||
motion.origin = validated_data.get('origin', '')
|
||||
motion.comments = validated_data.get('comments')
|
||||
motion.parent = validated_data.get('parent')
|
||||
motion.reset_state(validated_data.get('workflow_id'))
|
||||
motion.agenda_item_update_information['type'] = validated_data.get('agenda_type')
|
||||
@ -467,38 +455,17 @@ class MotionSerializer(ModelSerializer):
|
||||
"""
|
||||
Customized method to update a motion.
|
||||
"""
|
||||
# Identifier, category, motion_block, origin and comments.
|
||||
for key in ('identifier', 'category', 'motion_block', 'origin', 'comments'):
|
||||
if key in validated_data.keys():
|
||||
setattr(motion, key, validated_data[key])
|
||||
workflow_id = None
|
||||
if 'workflow_id' in validated_data:
|
||||
workflow_id = validated_data.pop('workflow_id')
|
||||
|
||||
result = super().update(motion, validated_data)
|
||||
|
||||
# Workflow.
|
||||
workflow_id = validated_data.get('workflow_id')
|
||||
if workflow_id is not None and workflow_id != motion.workflow_id:
|
||||
motion.reset_state(workflow_id)
|
||||
motion.save()
|
||||
|
||||
# Decide if a new version is saved to the database.
|
||||
if (motion.state.versioning and
|
||||
not validated_data.get('disable_versioning', False)): # TODO
|
||||
version = motion.get_new_version()
|
||||
else:
|
||||
version = motion.get_last_version()
|
||||
|
||||
# Title, text, reason, ...
|
||||
for key in ('title', 'text', 'amendment_paragraphs', 'modified_final_version', 'reason'):
|
||||
if key in validated_data.keys():
|
||||
setattr(version, key, validated_data[key])
|
||||
|
||||
motion.save(use_version=version)
|
||||
|
||||
# Submitters, supporters, attachments and tags
|
||||
for key in ('submitters', 'supporters', 'attachments', 'tags'):
|
||||
if key in validated_data.keys():
|
||||
attr = getattr(motion, key)
|
||||
attr.clear()
|
||||
attr.add(*validated_data[key])
|
||||
|
||||
return motion
|
||||
return result
|
||||
|
||||
def get_state_required_permission_to_see(self, motion):
|
||||
"""
|
||||
|
@ -54,54 +54,44 @@ def create_builtin_workflows(sender, **kwargs):
|
||||
action_word='Permit',
|
||||
recommendation_label='Permission',
|
||||
allow_create_poll=True,
|
||||
allow_submitter_edit=True,
|
||||
versioning=True,
|
||||
leave_old_version_active=True)
|
||||
allow_submitter_edit=True)
|
||||
state_2_3 = State.objects.create(name=ugettext_noop('accepted'),
|
||||
workflow=workflow_2,
|
||||
action_word='Accept',
|
||||
recommendation_label='Acceptance',
|
||||
versioning=True,
|
||||
css_class='success')
|
||||
state_2_4 = State.objects.create(name=ugettext_noop('rejected'),
|
||||
workflow=workflow_2,
|
||||
action_word='Reject',
|
||||
recommendation_label='Rejection',
|
||||
versioning=True,
|
||||
css_class='danger')
|
||||
state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'),
|
||||
workflow=workflow_2,
|
||||
action_word='Withdraw',
|
||||
versioning=True,
|
||||
css_class='default')
|
||||
state_2_6 = State.objects.create(name=ugettext_noop('adjourned'),
|
||||
workflow=workflow_2,
|
||||
action_word='Adjourn',
|
||||
recommendation_label='Adjournment',
|
||||
versioning=True,
|
||||
css_class='default')
|
||||
state_2_7 = State.objects.create(name=ugettext_noop('not concerned'),
|
||||
workflow=workflow_2,
|
||||
action_word='Do not concern',
|
||||
recommendation_label='No concernment',
|
||||
versioning=True,
|
||||
css_class='default')
|
||||
state_2_8 = State.objects.create(name=ugettext_noop('refered to committee'),
|
||||
workflow=workflow_2,
|
||||
action_word='Refer to committee',
|
||||
recommendation_label='Referral to committee',
|
||||
versioning=True,
|
||||
css_class='default')
|
||||
state_2_9 = State.objects.create(name=ugettext_noop('needs review'),
|
||||
workflow=workflow_2,
|
||||
action_word='Needs review',
|
||||
versioning=True,
|
||||
css_class='default')
|
||||
state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'),
|
||||
workflow=workflow_2,
|
||||
action_word='Reject (not authorized)',
|
||||
recommendation_label='Rejection (not authorized)',
|
||||
versioning=True,
|
||||
css_class='default')
|
||||
state_2_1.next_states.add(state_2_2, state_2_5, state_2_10)
|
||||
state_2_2.next_states.add(state_2_3, state_2_4, state_2_5, state_2_6, state_2_7, state_2_8, state_2_9)
|
||||
|
@ -1,18 +1,17 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.http import Http404
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import ugettext as _, ugettext_noop
|
||||
from rest_framework import status
|
||||
|
||||
from ..core.config import config
|
||||
from ..utils.auth import has_perm
|
||||
from ..utils.auth import has_perm, in_some_groups
|
||||
from ..utils.autoupdate import inform_changed_data
|
||||
from ..utils.collection import CollectionElement
|
||||
from ..utils.exceptions import OpenSlidesError
|
||||
@ -32,6 +31,7 @@ from .access_permissions import (
|
||||
MotionAccessPermissions,
|
||||
MotionBlockAccessPermissions,
|
||||
MotionChangeRecommendationAccessPermissions,
|
||||
MotionCommentSectionAccessPermissions,
|
||||
WorkflowAccessPermissions,
|
||||
)
|
||||
from .exceptions import WorkflowError
|
||||
@ -40,8 +40,9 @@ from .models import (
|
||||
Motion,
|
||||
MotionBlock,
|
||||
MotionChangeRecommendation,
|
||||
MotionComment,
|
||||
MotionCommentSection,
|
||||
MotionPoll,
|
||||
MotionVersion,
|
||||
State,
|
||||
Submitter,
|
||||
Workflow,
|
||||
@ -56,7 +57,7 @@ class MotionViewSet(ModelViewSet):
|
||||
API endpoint for motions.
|
||||
|
||||
There are the following views: metadata, list, retrieve, create,
|
||||
partial_update, update, destroy, manage_version, support, set_state and
|
||||
partial_update, update, destroy, support, set_state and
|
||||
create_poll.
|
||||
"""
|
||||
access_permissions = MotionAccessPermissions()
|
||||
@ -77,7 +78,7 @@ class MotionViewSet(ModelViewSet):
|
||||
has_perm(self.request.user, 'motions.can_create') and
|
||||
(not config['motions_stop_submitting'] or
|
||||
has_perm(self.request.user, 'motions.can_manage')))
|
||||
elif self.action in ('manage_version', 'set_state', 'set_recommendation',
|
||||
elif self.action in ('set_state', 'manage_comments', 'set_recommendation',
|
||||
'follow_recommendation', 'create_poll', 'manage_submitters',
|
||||
'sort_submitters'):
|
||||
result = (has_perm(self.request.user, 'motions.can_see') and
|
||||
@ -130,7 +131,6 @@ class MotionViewSet(ModelViewSet):
|
||||
'title',
|
||||
'text',
|
||||
'reason',
|
||||
'comments', # This is checked later.
|
||||
]
|
||||
if parent_motion is not None:
|
||||
# For creating amendments.
|
||||
@ -146,16 +146,6 @@ class MotionViewSet(ModelViewSet):
|
||||
if key not in whitelist:
|
||||
del request.data[key]
|
||||
|
||||
# Check permission to send comment data.
|
||||
if (not has_perm(request.user, 'motions.can_see_comments') or
|
||||
not has_perm(request.user, 'motions.can_manage_comments')):
|
||||
try:
|
||||
# Ignore comments data if user is not allowed to send comments.
|
||||
del request.data['comments']
|
||||
except KeyError:
|
||||
# No comments here. Just do nothing.
|
||||
pass
|
||||
|
||||
# Validate data and create motion.
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@ -223,9 +213,7 @@ class MotionViewSet(ModelViewSet):
|
||||
|
||||
# Check permissions.
|
||||
if (not has_perm(request.user, 'motions.can_manage') and
|
||||
not (motion.is_submitter(request.user) and motion.state.allow_submitter_edit) and
|
||||
not (has_perm(request.user, 'motions.can_see_comments') and
|
||||
has_perm(request.user, 'motions.can_manage_comments'))):
|
||||
not (motion.is_submitter(request.user) and motion.state.allow_submitter_edit)):
|
||||
self.permission_denied(request)
|
||||
|
||||
# Check permission to send only some data.
|
||||
@ -233,9 +221,7 @@ class MotionViewSet(ModelViewSet):
|
||||
# Remove fields that the user is not allowed to change.
|
||||
# The list() is required because we want to use del inside the loop.
|
||||
keys = list(request.data.keys())
|
||||
whitelist = [
|
||||
'comments', # This is checked later.
|
||||
]
|
||||
whitelist: List[str] = []
|
||||
# Add title, text and reason to the whitelist only, if the user is the submitter.
|
||||
if motion.is_submitter(request.user) and motion.state.allow_submitter_edit:
|
||||
whitelist.extend((
|
||||
@ -247,70 +233,22 @@ class MotionViewSet(ModelViewSet):
|
||||
if key not in whitelist:
|
||||
del request.data[key]
|
||||
|
||||
# Check comments
|
||||
# "normal" comments only can be changed if the user has can_see_comments and
|
||||
# can_manage_comments.
|
||||
# "special" comments (for state and recommendation) can only be changed, if
|
||||
# the user has the can_manage permission
|
||||
if 'comments' in request.data:
|
||||
request_comments = {} # Here, all valid comments are saved.
|
||||
for id, value in request.data['comments'].items():
|
||||
field = config['motions_comments'].get(id)
|
||||
if field:
|
||||
is_special_comment = field.get('forRecommendation') or field.get('forState')
|
||||
if (is_special_comment and has_perm(request.user, 'motions.can_manage')) or (
|
||||
not is_special_comment and has_perm(request.user, 'motions.can_see_comments') and
|
||||
has_perm(request.user, 'motions.can_manage_comments')):
|
||||
# The user has the required permission for at least one case
|
||||
# Save the comment!
|
||||
request_comments[id] = value
|
||||
|
||||
# two possibilities here: Either the comments dict is empty: then delete it, so
|
||||
# the serializer will skip it. If we leave it empty, everything will be deleted :(
|
||||
# Second, just special or normal comments are in the comments dict. Fill the original
|
||||
# data, so it won't be delete.
|
||||
|
||||
if len(request_comments) == 0:
|
||||
del request.data['comments']
|
||||
else:
|
||||
if motion.comments:
|
||||
for id, value in motion.comments.items():
|
||||
if id not in request_comments:
|
||||
# populate missing comments with original ones.
|
||||
request_comments[id] = value
|
||||
request.data['comments'] = request_comments
|
||||
|
||||
# get changed comment fields
|
||||
changed_comment_fields = []
|
||||
comments = request.data.get('comments', {})
|
||||
for id, value in comments.items():
|
||||
if not motion.comments or motion.comments.get(id) != value:
|
||||
field = config['motions_comments'].get(id)
|
||||
if field:
|
||||
name = field['name']
|
||||
changed_comment_fields.append(name)
|
||||
|
||||
# Validate data and update motion.
|
||||
serializer = self.get_serializer(
|
||||
motion,
|
||||
data=request.data,
|
||||
partial=kwargs.get('partial', False))
|
||||
serializer.is_valid(raise_exception=True)
|
||||
updated_motion = serializer.save(disable_versioning=request.data.get('disable_versioning'))
|
||||
updated_motion = serializer.save()
|
||||
|
||||
# Write the log message, check removal of supporters and initiate response.
|
||||
# TODO: Log if a version was updated.
|
||||
# TODO: Log if a motion was updated.
|
||||
updated_motion.write_log([ugettext_noop('Motion updated')], request.user)
|
||||
if (config['motions_remove_supporters'] and updated_motion.state.allow_support and
|
||||
not has_perm(request.user, 'motions.can_manage')):
|
||||
updated_motion.supporters.clear()
|
||||
updated_motion.write_log([ugettext_noop('All supporters removed')], request.user)
|
||||
|
||||
if len(changed_comment_fields) > 0:
|
||||
updated_motion.write_log(
|
||||
[ugettext_noop('Comment {} updated').format(', '.join(changed_comment_fields))],
|
||||
request.user)
|
||||
|
||||
# Send new supporters via autoupdate because users
|
||||
# without permission to see users may not have them but can get it now.
|
||||
new_users = list(updated_motion.supporters.all())
|
||||
@ -318,48 +256,66 @@ class MotionViewSet(ModelViewSet):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@detail_route(methods=['put', 'delete'])
|
||||
def manage_version(self, request, pk=None):
|
||||
@detail_route(methods=['POST', 'DELETE'])
|
||||
def manage_comments(self, request, pk=None):
|
||||
"""
|
||||
Special view endpoint to permit and delete a version of a motion.
|
||||
|
||||
Send PUT {'version_number': <number>} to permit and DELETE
|
||||
{'version_number': <number>} to delete a version. Deleting the
|
||||
active version is not allowed. Only managers can use this view.
|
||||
Create, update and delete motin comments.
|
||||
Send a post request with {'section_id': <id>, 'comment': '<comment>'} to create
|
||||
a new comment or update an existing comment.
|
||||
Send a delete request with just {'section_id': <id>} to delete the comment.
|
||||
For ever request, the user must have read and write permission for the given field.
|
||||
"""
|
||||
# Retrieve motion and version.
|
||||
motion = self.get_object()
|
||||
version_number = request.data.get('version_number')
|
||||
|
||||
# Get the comment section
|
||||
section_id = request.data.get('section_id')
|
||||
if not section_id or not isinstance(section_id, int):
|
||||
raise ValidationError({'detail': _('You have to provide a section_id of type int.')})
|
||||
|
||||
try:
|
||||
version = motion.versions.get(version_number=version_number)
|
||||
except MotionVersion.DoesNotExist:
|
||||
raise Http404('Version %s not found.' % version_number)
|
||||
section = MotionCommentSection.objects.get(pk=section_id)
|
||||
except MotionCommentSection.DoesNotExist:
|
||||
raise ValidationError({'detail': _('A comment section with id {} does not exist').format(section_id)})
|
||||
|
||||
# Permit or delete version.
|
||||
if request.method == 'PUT':
|
||||
# Permit version.
|
||||
motion.active_version = version
|
||||
motion.save(update_fields=['active_version'])
|
||||
motion.write_log(
|
||||
message_list=[ugettext_noop('Version'),
|
||||
' %d ' % version.version_number,
|
||||
ugettext_noop('permitted')],
|
||||
person=self.request.user)
|
||||
message = _('Version %d permitted successfully.') % version.version_number
|
||||
else:
|
||||
# Delete version.
|
||||
# request.method == 'DELETE'
|
||||
if version == motion.active_version:
|
||||
raise ValidationError({'detail': _('You can not delete the active version of a motion.')})
|
||||
version.delete()
|
||||
motion.write_log(
|
||||
message_list=[ugettext_noop('Version'),
|
||||
' %d ' % version.version_number,
|
||||
ugettext_noop('deleted')],
|
||||
person=self.request.user)
|
||||
message = _('Version %d deleted successfully.') % version.version_number
|
||||
# the request user needs to see and write to the comment section
|
||||
if (not in_some_groups(request.user, list(section.read_groups.values_list('pk', flat=True))) or
|
||||
not in_some_groups(request.user, list(section.write_groups.values_list('pk', flat=True)))):
|
||||
raise ValidationError({'detail': _('You are not allowed to see or write to the comment section.')})
|
||||
|
||||
if request.method == 'POST': # Create or update
|
||||
# validate comment
|
||||
comment_value = request.data.get('comment', '')
|
||||
if not isinstance(comment_value, str):
|
||||
raise ValidationError({'detail': _('The comment should be a string.')})
|
||||
|
||||
comment, created = MotionComment.objects.get_or_create(
|
||||
motion=motion,
|
||||
section=section,
|
||||
defaults={
|
||||
'comment': comment_value})
|
||||
if not created:
|
||||
comment.comment = comment_value
|
||||
comment.save()
|
||||
|
||||
# write log
|
||||
motion.write_log(
|
||||
[ugettext_noop('Comment {} updated').format(section.name)],
|
||||
request.user)
|
||||
message = _('Comment {} updated').format(section.name)
|
||||
else: # DELETE
|
||||
try:
|
||||
comment = MotionComment.objects.get(motion=motion, section=section)
|
||||
except MotionComment.DoesNotExist:
|
||||
# Be silent about not existing comments, but do not create a log entry.
|
||||
pass
|
||||
else:
|
||||
comment.delete()
|
||||
|
||||
motion.write_log(
|
||||
[ugettext_noop('Comment {} deleted').format(section.name)],
|
||||
request.user)
|
||||
message = _('Comment {} deleted').format(section.name)
|
||||
|
||||
# Initiate response.
|
||||
return Response({'detail': message})
|
||||
|
||||
@detail_route(methods=['POST', 'DELETE'])
|
||||
@ -588,17 +544,13 @@ class MotionViewSet(ModelViewSet):
|
||||
motion.set_state(motion.recommendation)
|
||||
|
||||
# Set the special state comment.
|
||||
extension = request.data.get('recommendationExtension')
|
||||
extension = request.data.get('state_extension')
|
||||
if extension is not None:
|
||||
# Find the special "state" comment field.
|
||||
for id, field in config['motions_comments'].items():
|
||||
if isinstance(field, dict) and 'forState' in field and field['forState'] is True:
|
||||
motion.comments[id] = extension
|
||||
break
|
||||
motion.state_extension = extension
|
||||
|
||||
# Save and write log.
|
||||
motion.save(
|
||||
update_fields=['state', 'identifier', 'identifier_number', 'comments'],
|
||||
update_fields=['state', 'identifier', 'identifier_number', 'state_extension'],
|
||||
skip_autoupdate=True)
|
||||
motion.write_log(
|
||||
message_list=[ugettext_noop('State set to'), ' ', motion.state.name],
|
||||
@ -700,6 +652,51 @@ class MotionChangeRecommendationViewSet(ModelViewSet):
|
||||
return Response({'detail': err.message}, status=400)
|
||||
|
||||
|
||||
class MotionCommentSectionViewSet(ModelViewSet):
|
||||
"""
|
||||
API endpoint for motion comment fields.
|
||||
"""
|
||||
access_permissions = MotionCommentSectionAccessPermissions()
|
||||
queryset = MotionCommentSection.objects.all()
|
||||
|
||||
def check_view_permissions(self):
|
||||
"""
|
||||
Returns True if the user has required permissions.
|
||||
"""
|
||||
if self.action in ('list', 'retrieve'):
|
||||
result = self.get_access_permissions().check_permissions(self.request.user)
|
||||
elif self.action in ('create', 'destroy', 'update', 'partial_update'):
|
||||
result = (has_perm(self.request.user, 'motions.can_see') and
|
||||
has_perm(self.request.user, 'motions.can_manage'))
|
||||
else:
|
||||
result = False
|
||||
return result
|
||||
|
||||
def destroy(self, *args, **kwargs):
|
||||
"""
|
||||
Customized view endpoint to delete a motion comment section. Will return
|
||||
an error for the user, if still comments for this section exist.
|
||||
"""
|
||||
try:
|
||||
result = super().destroy(*args, **kwargs)
|
||||
except ProtectedError as e:
|
||||
# The protected objects can just be motion comments.
|
||||
motions = ['"' + str(comment.motion) + '"' for comment in e.protected_objects.all()]
|
||||
count = len(motions)
|
||||
motions_verbose = ', '.join(motions[:3])
|
||||
if count > 3:
|
||||
motions_verbose += ', ...'
|
||||
|
||||
if count == 1:
|
||||
msg = _('This section has still comments in motion {}.').format(motions_verbose)
|
||||
else:
|
||||
msg = _('This section has still comments in motions {}.').format(motions_verbose)
|
||||
|
||||
msg += ' ' + _('Please remove all comments before deletion.')
|
||||
raise ValidationError({'detail': msg})
|
||||
return result
|
||||
|
||||
|
||||
class CategoryViewSet(ModelViewSet):
|
||||
"""
|
||||
API endpoint for categories.
|
||||
@ -920,7 +917,7 @@ class WorkflowViewSet(ModelViewSet, ProtectedErrorMessageMixin):
|
||||
|
||||
def destroy(self, *args, **kwargs):
|
||||
"""
|
||||
Customized view endpoint to delete a motion poll.
|
||||
Customized view endpoint to delete a workflow.
|
||||
"""
|
||||
try:
|
||||
result = super().destroy(*args, **kwargs)
|
||||
@ -949,7 +946,7 @@ class StateViewSet(CreateModelMixin, UpdateModelMixin, DestroyModelMixin, Generi
|
||||
|
||||
def destroy(self, *args, **kwargs):
|
||||
"""
|
||||
Customized view endpoint to delete a motion poll.
|
||||
Customized view endpoint to delete a state.
|
||||
"""
|
||||
state = self.get_object()
|
||||
if state.workflow.first_state.pk == state.pk: # is this the first state of the workflow?
|
||||
|
@ -53,8 +53,6 @@ def create_builtin_groups_and_admin(**kwargs):
|
||||
'motions.can_create',
|
||||
'motions.can_manage',
|
||||
'motions.can_see',
|
||||
'motions.can_see_comments',
|
||||
'motions.can_manage_comments',
|
||||
'motions.can_support',
|
||||
'users.can_manage',
|
||||
'users.can_see_extra_data',
|
||||
@ -124,8 +122,6 @@ def create_builtin_groups_and_admin(**kwargs):
|
||||
permission_dict['motions.can_see'],
|
||||
permission_dict['motions.can_create'],
|
||||
permission_dict['motions.can_manage'],
|
||||
permission_dict['motions.can_see_comments'],
|
||||
permission_dict['motions.can_manage_comments'],
|
||||
permission_dict['users.can_see_name'],
|
||||
permission_dict['users.can_manage'],
|
||||
permission_dict['users.can_see_extra_data'],
|
||||
@ -158,8 +154,6 @@ def create_builtin_groups_and_admin(**kwargs):
|
||||
permission_dict['motions.can_see'],
|
||||
permission_dict['motions.can_create'],
|
||||
permission_dict['motions.can_manage'],
|
||||
permission_dict['motions.can_see_comments'],
|
||||
permission_dict['motions.can_manage_comments'],
|
||||
permission_dict['users.can_see_name'],
|
||||
permission_dict['users.can_manage'],
|
||||
permission_dict['users.can_see_extra_data'],
|
||||
|
@ -1,13 +1,33 @@
|
||||
from typing import Dict, Optional, Union, cast
|
||||
from typing import Dict, List, Optional, Union, cast
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Model
|
||||
|
||||
from .cache import element_cache
|
||||
from .collection import CollectionElement
|
||||
|
||||
|
||||
GROUP_DEFAULT_PK = 1 # This is the hard coded pk for the default group.
|
||||
|
||||
|
||||
def get_group_model() -> Model:
|
||||
"""
|
||||
Return the Group model that is active in this project.
|
||||
"""
|
||||
try:
|
||||
return apps.get_model(settings.AUTH_GROUP_MODEL, require_ready=False)
|
||||
except ValueError:
|
||||
raise ImproperlyConfigured("AUTH_GROUP_MODEL must be of the form 'app_label.model_name'")
|
||||
except LookupError:
|
||||
raise ImproperlyConfigured(
|
||||
"AUTH_GROUP_MODEL refers to model '%s' that has not been installed" % settings.AUTH_GROUP_MODEL
|
||||
)
|
||||
|
||||
|
||||
def has_perm(user: Optional[CollectionElement], perm: str) -> bool:
|
||||
"""
|
||||
Checks that user has a specific permission.
|
||||
@ -22,13 +42,13 @@ def has_perm(user: Optional[CollectionElement], perm: str) -> bool:
|
||||
if user is None and not anonymous_is_enabled():
|
||||
has_perm = False
|
||||
elif user is None:
|
||||
# Use the permissions from the default group with id 1.
|
||||
default_group = CollectionElement.from_values(group_collection_string, 1)
|
||||
# Use the permissions from the default group.
|
||||
default_group = CollectionElement.from_values(group_collection_string, GROUP_DEFAULT_PK)
|
||||
has_perm = perm in default_group.get_full_data()['permissions']
|
||||
else:
|
||||
# Get all groups of the user and then see, if one group has the required
|
||||
# permission. If the user has no groups, then use group 1.
|
||||
group_ids = user.get_full_data()['groups_id'] or [1]
|
||||
# permission. If the user has no groups, then use the default group.
|
||||
group_ids = user.get_full_data()['groups_id'] or [GROUP_DEFAULT_PK]
|
||||
for group_id in group_ids:
|
||||
group = CollectionElement.from_values(group_collection_string, group_id)
|
||||
if perm in group.get_full_data()['permissions']:
|
||||
@ -39,6 +59,38 @@ def has_perm(user: Optional[CollectionElement], perm: str) -> bool:
|
||||
return has_perm
|
||||
|
||||
|
||||
def in_some_groups(user: Optional[CollectionElement], groups: List[int]) -> bool:
|
||||
"""
|
||||
Checks that user is in at least one given group. Groups can be given as a list
|
||||
of ids or group instances.
|
||||
|
||||
User can be a CollectionElement of a user or None.
|
||||
"""
|
||||
|
||||
if len(groups) == 0:
|
||||
return False # early end here, if no groups are given.
|
||||
|
||||
# Convert user to right type
|
||||
# TODO: Remove this and make use, that user has always the right type
|
||||
user = user_to_collection_user(user)
|
||||
if user is None and not anonymous_is_enabled():
|
||||
in_some_groups = False
|
||||
elif user is None:
|
||||
# Use the permissions from the default group.
|
||||
in_some_groups = GROUP_DEFAULT_PK in groups
|
||||
else:
|
||||
# Get all groups of the user and then see, if one group has the required
|
||||
# permission. If the user has no groups, then use the default group.
|
||||
group_ids = user.get_full_data()['groups_id'] or [GROUP_DEFAULT_PK]
|
||||
for group_id in group_ids:
|
||||
if group_id in groups:
|
||||
in_some_groups = True
|
||||
break
|
||||
else:
|
||||
in_some_groups = False
|
||||
return in_some_groups
|
||||
|
||||
|
||||
def anonymous_is_enabled() -> bool:
|
||||
"""
|
||||
Returns True if the anonymous user is enabled in the settings.
|
||||
|
@ -99,7 +99,6 @@ def test_agenda_item_db_queries():
|
||||
* 3 requests to get the assignments, motions and topics and
|
||||
|
||||
* 1 request to get an agenda item (why?)
|
||||
* 2 requests for the motionsversions.
|
||||
TODO: The last three request are a bug.
|
||||
"""
|
||||
for index in range(10):
|
||||
@ -112,7 +111,7 @@ def test_agenda_item_db_queries():
|
||||
Motion.objects.create(title='motion2')
|
||||
Assignment.objects.create(title='assignment', open_posts=5)
|
||||
|
||||
assert count_queries(Item.get_elements) == 8
|
||||
assert count_queries(Item.get_elements) == 6
|
||||
|
||||
|
||||
class ManageSpeaker(TestCase):
|
||||
|
@ -2,23 +2,24 @@ import json
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from openslides.core.config import config
|
||||
from openslides.core.models import ConfigStore, Tag
|
||||
from openslides.core.models import Tag
|
||||
from openslides.motions.models import (
|
||||
Category,
|
||||
Motion,
|
||||
MotionBlock,
|
||||
MotionComment,
|
||||
MotionCommentSection,
|
||||
MotionLog,
|
||||
State,
|
||||
Submitter,
|
||||
Workflow,
|
||||
)
|
||||
from openslides.users.models import Group
|
||||
from openslides.utils.auth import get_group_model
|
||||
from openslides.utils.collection import CollectionElement
|
||||
from openslides.utils.test import TestCase
|
||||
|
||||
@ -31,22 +32,39 @@ def test_motion_db_queries():
|
||||
Tests that only the following db queries are done:
|
||||
* 1 requests to get the list of all motions,
|
||||
* 1 request to get the associated workflow
|
||||
* 1 request to get the motion versions,
|
||||
* 1 request for all motion comments
|
||||
* 1 request for all motion comment sections required for the comments
|
||||
* 1 request for all users required for the read_groups of the sections
|
||||
* 1 request to get the agenda item,
|
||||
* 1 request to get the motion log,
|
||||
* 1 request to get the polls,
|
||||
* 1 request to get the attachments,
|
||||
* 1 request to get the tags,
|
||||
* 2 requests to get the submitters and supporters.
|
||||
|
||||
Two comment sections are created and for each motions two comments.
|
||||
"""
|
||||
section1 = MotionCommentSection.objects.create(name='test_section')
|
||||
section2 = MotionCommentSection.objects.create(name='test_section')
|
||||
|
||||
for index in range(10):
|
||||
Motion.objects.create(title='motion{}'.format(index))
|
||||
motion = Motion.objects.create(title='motion{}'.format(index))
|
||||
|
||||
MotionComment.objects.create(
|
||||
comment='test_comment',
|
||||
motion=motion,
|
||||
section=section1)
|
||||
MotionComment.objects.create(
|
||||
comment='test_comment2',
|
||||
motion=motion,
|
||||
section=section2)
|
||||
|
||||
get_user_model().objects.create_user(
|
||||
username='user_{}'.format(index),
|
||||
password='password')
|
||||
# TODO: Create some polls etc.
|
||||
|
||||
assert count_queries(Motion.get_elements) == 10
|
||||
assert count_queries(Motion.get_elements) == 12
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=False)
|
||||
@ -169,45 +187,6 @@ class CreateMotion(TestCase):
|
||||
motion = Motion.objects.get()
|
||||
self.assertEqual(motion.tags.get().name, 'test_tag_iRee3kiecoos4rorohth')
|
||||
|
||||
def test_with_multiple_comments(self):
|
||||
comments = {
|
||||
'1': 'comemnt1_sdpoiuffo3%7dwDwW)',
|
||||
'2': 'comment2_iusd_D/TdskDWH(5DWas46WAd078'}
|
||||
response = self.client.post(
|
||||
reverse('motion-list'),
|
||||
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
||||
'text': 'text_test_fiuhefF86()ew1Ef346AF6W',
|
||||
'comments': comments},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
motion = Motion.objects.get()
|
||||
self.assertEqual(motion.comments, comments)
|
||||
|
||||
def test_wrong_comment_format(self):
|
||||
comments = [
|
||||
'comemnt1_wpcjlwgj$§ks)skj2LdmwKDWSLw6',
|
||||
'comment2_dq2Wd)Jwdlmm:,w82DjwQWSSiwjd']
|
||||
response = self.client.post(
|
||||
reverse('motion-list'),
|
||||
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
||||
'text': 'text_test_fiuhefF86()ew1Ef346AF6W',
|
||||
'comments': comments},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data, {'comments': {'detail': 'Data must be a dict.'}})
|
||||
|
||||
def test_wrong_comment_id(self):
|
||||
comment = {
|
||||
'string': 'comemnt1_wpcjlwgj$§ks)skj2LdmwKDWSLw6'}
|
||||
response = self.client.post(
|
||||
reverse('motion-list'),
|
||||
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
||||
'text': 'text_test_fiuhefF86()ew1Ef346AF6W',
|
||||
'comments': comment},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data, {'comments': {'detail': 'Id must be an int.'}})
|
||||
|
||||
def test_with_workflow(self):
|
||||
"""
|
||||
Test to create a motion with a specific workflow.
|
||||
@ -227,7 +206,7 @@ class CreateMotion(TestCase):
|
||||
"""
|
||||
self.admin = get_user_model().objects.get(username='admin')
|
||||
self.admin.groups.add(2)
|
||||
self.admin.groups.remove(3)
|
||||
self.admin.groups.remove(4)
|
||||
|
||||
response = self.client.post(
|
||||
reverse('motion-list'),
|
||||
@ -236,35 +215,6 @@ class CreateMotion(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_non_admin_with_comment_data(self):
|
||||
"""
|
||||
Test to create a motion by a non staff user that has permission to
|
||||
manage motion comments and sends some additional fields.
|
||||
"""
|
||||
self.admin = get_user_model().objects.get(username='admin')
|
||||
self.admin.groups.add(2)
|
||||
self.admin.groups.remove(4)
|
||||
group_delegate = self.admin.groups.get()
|
||||
group_delegate.permissions.add(Permission.objects.get(
|
||||
content_type__app_label='motions',
|
||||
codename='can_manage_comments',
|
||||
))
|
||||
group_delegate.permissions.add(Permission.objects.get(
|
||||
content_type__app_label='motions',
|
||||
codename='can_see_comments',
|
||||
))
|
||||
|
||||
response = self.client.post(
|
||||
reverse('motion-list'),
|
||||
{'title': 'test_title_peiJozae0luew9EeL8bo',
|
||||
'text': 'test_text_eHohS8ohr5ahshoah8Oh',
|
||||
'comments': {'1': 'comment_for_field_one__xiek1Euhae9xah2wuuraaaa'}},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Motion.objects.get().comments, {'1': 'comment_for_field_one__xiek1Euhae9xah2wuuraaaa'})
|
||||
|
||||
def test_amendment_motion(self):
|
||||
"""
|
||||
Test to create a motion with a parent motion as staff user.
|
||||
@ -383,7 +333,7 @@ class RetrieveMotion(TestCase):
|
||||
def test_user_without_can_see_user_permission_to_see_motion_and_submitter_data(self):
|
||||
admin = get_user_model().objects.get(username='admin')
|
||||
Submitter.objects.add(admin, self.motion)
|
||||
group = Group.objects.get(pk=1) # Group with pk 1 is for anonymous and default users.
|
||||
group = get_group_model().objects.get(pk=1) # Group with pk 1 is for anonymous and default users.
|
||||
permission_string = 'users.can_see_name'
|
||||
app_label, codename = permission_string.split('.')
|
||||
permission = group.permissions.get(content_type__app_label=app_label, codename=codename)
|
||||
@ -491,56 +441,6 @@ class UpdateMotion(TestCase):
|
||||
self.assertEqual(motion.title, 'new_title_ohph1aedie5Du8sai2ye')
|
||||
self.assertEqual(motion.supporters.count(), 0)
|
||||
|
||||
def test_with_new_version(self):
|
||||
self.motion.set_state(State.objects.get(name='permitted'))
|
||||
self.motion.save()
|
||||
response = self.client.patch(
|
||||
reverse('motion-detail', args=[self.motion.pk]),
|
||||
{'text': 'test_text_aeb1iaghahChong5od3a'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
motion = Motion.objects.get()
|
||||
self.assertEqual(motion.versions.count(), 2)
|
||||
|
||||
def test_without_new_version(self):
|
||||
self.motion.set_state(State.objects.get(name='permitted'))
|
||||
self.motion.save()
|
||||
response = self.client.patch(
|
||||
reverse('motion-detail', args=[self.motion.pk]),
|
||||
{'text': 'test_text_aeThaeroneiroo7Iophu',
|
||||
'disable_versioning': True})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
motion = Motion.objects.get()
|
||||
self.assertEqual(motion.versions.count(), 1)
|
||||
|
||||
def test_update_comment_creates_log_entry(self):
|
||||
field_name = 'comment_field_name_texl2i7%sookqerpl29a'
|
||||
config['motions_comments'] = {
|
||||
'1': {
|
||||
'name': field_name,
|
||||
'public': False
|
||||
}
|
||||
}
|
||||
|
||||
# Update Config cache
|
||||
CollectionElement.from_instance(
|
||||
ConfigStore.objects.get(key='motions_comments')
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
reverse('motion-detail', args=[self.motion.pk]),
|
||||
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
||||
'text': 'text_test_fiuhefF86()ew1Ef346AF6W',
|
||||
'comments': {'1': 'comment1_sdpoiuffo3%7dwDwW)'}
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
motion_logs = MotionLog.objects.filter(motion=self.motion)
|
||||
self.assertEqual(motion_logs.count(), 2)
|
||||
|
||||
motion_log = motion_logs.order_by('-time').first()
|
||||
self.assertTrue(field_name in motion_log.message_list[0])
|
||||
|
||||
|
||||
class DeleteMotion(TestCase):
|
||||
"""
|
||||
@ -564,7 +464,7 @@ class DeleteMotion(TestCase):
|
||||
|
||||
def make_admin_delegate(self):
|
||||
group_admin = self.admin.groups.get(name='Admin')
|
||||
group_delegates = Group.objects.get(name='Delegates')
|
||||
group_delegates = get_group_model().objects.get(name='Delegates')
|
||||
self.admin.groups.remove(group_admin)
|
||||
self.admin.groups.add(group_delegates)
|
||||
CollectionElement.from_instance(self.admin)
|
||||
@ -594,51 +494,6 @@ class DeleteMotion(TestCase):
|
||||
self.assertEqual(motions, 0)
|
||||
|
||||
|
||||
class ManageVersion(TestCase):
|
||||
"""
|
||||
Tests permitting and deleting versions.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.client.login(username='admin', password='admin')
|
||||
self.motion = Motion(
|
||||
title='test_title_InieJ5HieZieg1Meid7K',
|
||||
text='test_text_daePhougho7EenguWe4g')
|
||||
self.motion.save()
|
||||
self.version_2 = self.motion.get_new_version(title='new_title_fee7tef0seechazeefiW')
|
||||
self.motion.save(use_version=self.version_2)
|
||||
|
||||
def test_permit(self):
|
||||
self.assertEqual(Motion.objects.get(pk=self.motion.pk).active_version.version_number, 2)
|
||||
response = self.client.put(
|
||||
reverse('motion-manage-version', args=[self.motion.pk]),
|
||||
{'version_number': '1'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, {'detail': 'Version 1 permitted successfully.'})
|
||||
self.assertEqual(Motion.objects.get(pk=self.motion.pk).active_version.version_number, 1)
|
||||
|
||||
def test_permit_invalid_version(self):
|
||||
response = self.client.put(
|
||||
reverse('motion-manage-version', args=[self.motion.pk]),
|
||||
{'version_number': '3'})
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_delete(self):
|
||||
response = self.client.delete(
|
||||
reverse('motion-manage-version', args=[self.motion.pk]),
|
||||
{'version_number': '1'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, {'detail': 'Version 1 deleted successfully.'})
|
||||
self.assertEqual(Motion.objects.get(pk=self.motion.pk).versions.count(), 1)
|
||||
|
||||
def test_delete_active_version(self):
|
||||
response = self.client.delete(
|
||||
reverse('motion-manage-version', args=[self.motion.pk]),
|
||||
{'version_number': '2'})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data, {'detail': 'You can not delete the active version of a motion.'})
|
||||
|
||||
|
||||
class ManageSubmitters(TestCase):
|
||||
"""
|
||||
Tests adding and removing of submitters.
|
||||
@ -751,6 +606,506 @@ class ManageSubmitters(TestCase):
|
||||
self.assertEqual(self.motion.submitters.count(), 0)
|
||||
|
||||
|
||||
class ManageComments(TestCase):
|
||||
"""
|
||||
Tests the manage_comment view.
|
||||
|
||||
Tests creation/updating and deletion of motion comments.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.client.login(username='admin', password='admin')
|
||||
|
||||
self.admin = get_user_model().objects.get()
|
||||
self.group_in = get_group_model().objects.get(pk=4)
|
||||
self.group_out = get_group_model().objects.get(pk=2) # The admin should not be in this group
|
||||
|
||||
self.motion = Motion(
|
||||
title='test_title_SlqfMw(waso0saWMPqcZ',
|
||||
text='test_text_f30skclqS9wWF=xdfaSL')
|
||||
self.motion.save()
|
||||
|
||||
self.section_no_groups = MotionCommentSection(name='test_name_gj4F§(fj"(edm"§F3f3fs')
|
||||
self.section_no_groups.save()
|
||||
|
||||
self.section_read = MotionCommentSection(name='test_name_2wv30(d2S&kvelkakl39')
|
||||
self.section_read.save()
|
||||
self.section_read.read_groups.add(self.group_in, self.group_out) # Group out for testing multiple groups
|
||||
self.section_read.write_groups.add(self.group_out)
|
||||
|
||||
self.section_read_write = MotionCommentSection(name='test_name_a3m9sd0(Mw2%slkrv30,')
|
||||
self.section_read_write.save()
|
||||
self.section_read_write.read_groups.add(self.group_in)
|
||||
self.section_read_write.write_groups.add(self.group_in)
|
||||
|
||||
def test_retrieve_comment(self):
|
||||
comment = MotionComment(
|
||||
motion=self.motion,
|
||||
section=self.section_read_write,
|
||||
comment='test_comment_gwic37Csc&3lf3eo2')
|
||||
comment.save()
|
||||
|
||||
response = self.client.get(reverse('motion-detail', args=[self.motion.pk]))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue('comments' in response.data)
|
||||
comments = response.data['comments']
|
||||
self.assertTrue(isinstance(comments, list))
|
||||
self.assertEqual(len(comments), 1)
|
||||
self.assertEqual(comments[0]['comment'], 'test_comment_gwic37Csc&3lf3eo2')
|
||||
|
||||
def test_retrieve_comment_no_read_permission(self):
|
||||
comment = MotionComment(
|
||||
motion=self.motion,
|
||||
section=self.section_no_groups,
|
||||
comment='test_comment_fgkj3C7veo3ijWE(j2DJ')
|
||||
comment.save()
|
||||
|
||||
response = self.client.get(reverse('motion-detail', args=[self.motion.pk]))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue('comments' in response.data)
|
||||
comments = response.data['comments']
|
||||
self.assertTrue(isinstance(comments, list))
|
||||
self.assertEqual(len(comments), 0)
|
||||
|
||||
def test_wrong_data_type(self):
|
||||
response = self.client.post(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
None,
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data['detail'],
|
||||
'You have to provide a section_id of type int.')
|
||||
|
||||
def test_wrong_comment_data_type(self):
|
||||
response = self.client.post(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read_write.id,
|
||||
'comment': [32, 'no_correct_data']
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data['detail'],
|
||||
'The comment should be a string.')
|
||||
|
||||
def test_non_existing_section(self):
|
||||
response = self.client.post(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': 42,
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data['detail'],
|
||||
'A comment section with id 42 does not exist')
|
||||
|
||||
def test_create_comment(self):
|
||||
response = self.client.post(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read_write.pk,
|
||||
'comment': 'test_comment_fk3jrnfwsdg%fj=feijf'
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(MotionComment.objects.count(), 1)
|
||||
comment = MotionComment.objects.get()
|
||||
self.assertEqual(comment.comment, 'test_comment_fk3jrnfwsdg%fj=feijf')
|
||||
|
||||
# Check for a log entry
|
||||
motion_logs = MotionLog.objects.filter(motion=self.motion)
|
||||
self.assertEqual(motion_logs.count(), 1)
|
||||
comment_log = motion_logs.get()
|
||||
self.assertTrue(self.section_read_write.name in comment_log.message_list[0])
|
||||
|
||||
def test_update_comment(self):
|
||||
comment = MotionComment(
|
||||
motion=self.motion,
|
||||
section=self.section_read_write,
|
||||
comment='test_comment_fji387fqwdf&ff=)Fe3j')
|
||||
comment.save()
|
||||
|
||||
response = self.client.post(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read_write.pk,
|
||||
'comment': 'test_comment_fk3jrnfwsdg%fj=feijf'
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
comment = MotionComment.objects.get()
|
||||
self.assertEqual(comment.comment, 'test_comment_fk3jrnfwsdg%fj=feijf')
|
||||
|
||||
# Check for a log entry
|
||||
motion_logs = MotionLog.objects.filter(motion=self.motion)
|
||||
self.assertEqual(motion_logs.count(), 1)
|
||||
comment_log = motion_logs.get()
|
||||
self.assertTrue(self.section_read_write.name in comment_log.message_list[0])
|
||||
|
||||
def test_delete_comment(self):
|
||||
comment = MotionComment(
|
||||
motion=self.motion,
|
||||
section=self.section_read_write,
|
||||
comment='test_comment_5CJ"8f23jd3j2,r93keZ')
|
||||
comment.save()
|
||||
|
||||
response = self.client.delete(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read_write.pk
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(MotionComment.objects.count(), 0)
|
||||
|
||||
# Check for a log entry
|
||||
motion_logs = MotionLog.objects.filter(motion=self.motion)
|
||||
self.assertEqual(motion_logs.count(), 1)
|
||||
comment_log = motion_logs.get()
|
||||
self.assertTrue(self.section_read_write.name in comment_log.message_list[0])
|
||||
|
||||
def test_delete_not_existing_comment(self):
|
||||
"""
|
||||
This should fail silently; no error, if the user wants to delete
|
||||
a not existing comment.
|
||||
"""
|
||||
response = self.client.delete(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read_write.pk
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(MotionComment.objects.count(), 0)
|
||||
|
||||
# Check that no log entry was created
|
||||
motion_logs = MotionLog.objects.filter(motion=self.motion)
|
||||
self.assertEqual(motion_logs.count(), 0)
|
||||
|
||||
def test_create_comment_no_write_permission(self):
|
||||
response = self.client.post(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read.pk,
|
||||
'comment': 'test_comment_f38jfwqfj830fj4j(FU3'
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(MotionComment.objects.count(), 0)
|
||||
self.assertEqual(
|
||||
response.data['detail'],
|
||||
'You are not allowed to see or write to the comment section.')
|
||||
|
||||
def test_update_comment_no_write_permission(self):
|
||||
comment = MotionComment(
|
||||
motion=self.motion,
|
||||
section=self.section_read,
|
||||
comment='test_comment_jg38dwiej2D832(D§dk)')
|
||||
comment.save()
|
||||
|
||||
response = self.client.post(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read.pk,
|
||||
'comment': 'test_comment_fk3jrnfwsdg%fj=feijf'
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
comment = MotionComment.objects.get()
|
||||
self.assertEqual(comment.comment, 'test_comment_jg38dwiej2D832(D§dk)')
|
||||
|
||||
def test_delete_comment_no_write_permission(self):
|
||||
comment = MotionComment(
|
||||
motion=self.motion,
|
||||
section=self.section_read,
|
||||
comment='test_comment_fej(NF§kfePOF383o8DN')
|
||||
comment.save()
|
||||
|
||||
response = self.client.delete(
|
||||
reverse('motion-manage-comments', args=[self.motion.pk]),
|
||||
{
|
||||
'section_id': self.section_read.pk
|
||||
},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(MotionComment.objects.count(), 1)
|
||||
comment = MotionComment.objects.get()
|
||||
self.assertEqual(comment.comment, 'test_comment_fej(NF§kfePOF383o8DN')
|
||||
|
||||
|
||||
class TestMotionCommentSection(TestCase):
|
||||
"""
|
||||
Tests creating, updating and deletion of comment sections.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.client.login(username='admin', password='admin')
|
||||
|
||||
self.admin = get_user_model().objects.get()
|
||||
self.group_in = get_group_model().objects.get(pk=4)
|
||||
self.group_out = get_group_model().objects.get(pk=2) # The admin should not be in this group
|
||||
|
||||
def test_retrieve(self):
|
||||
"""
|
||||
Checks, if the sections can be seen by a manager.
|
||||
"""
|
||||
section = MotionCommentSection(name='test_name_f3jOF3m8fp.<qiqmf32=')
|
||||
section.save()
|
||||
|
||||
response = self.client.get(reverse('motioncommentsection-list'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(isinstance(response.data, list))
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['name'], 'test_name_f3jOF3m8fp.<qiqmf32=')
|
||||
|
||||
def test_retrieve_non_manager_with_read_permission(self):
|
||||
"""
|
||||
Checks, if the sections can be seen by a non manager, but he is in
|
||||
one of the read_groups.
|
||||
"""
|
||||
self.admin.groups.remove(self.group_in) # group_in has motions.can_manage permission
|
||||
self.admin.groups.add(self.group_out) # group_out does not.
|
||||
|
||||
section = MotionCommentSection(name='test_name_f3mMD28LMcm29Coelwcm')
|
||||
section.save()
|
||||
section.read_groups.add(self.group_out, self.group_in)
|
||||
|
||||
response = self.client.get(reverse('motioncommentsection-list'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]['name'], 'test_name_f3mMD28LMcm29Coelwcm')
|
||||
|
||||
def test_retrieve_non_manager_no_read_permission(self):
|
||||
"""
|
||||
Checks, if sections are removed, if the user is a non manager and is in
|
||||
any of the read_groups.
|
||||
"""
|
||||
self.admin.groups.remove(self.group_in)
|
||||
|
||||
section = MotionCommentSection(name='test_name_f3jOF3m8fp.<qiqmf32=')
|
||||
section.save()
|
||||
section.read_groups.add(self.group_out)
|
||||
|
||||
response = self.client.get(reverse('motioncommentsection-list'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(isinstance(response.data, list))
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_create(self):
|
||||
"""
|
||||
Create a section just with a name.
|
||||
"""
|
||||
response = self.client.post(
|
||||
reverse('motioncommentsection-list'),
|
||||
{'name': 'test_name_ekjfen3n)F§zn83f§Fge'})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
MotionCommentSection.objects.get().name,
|
||||
'test_name_ekjfen3n)F§zn83f§Fge')
|
||||
|
||||
def test_create_no_permission(self):
|
||||
"""
|
||||
Try to create a section without can_manage permissions.
|
||||
"""
|
||||
self.admin.groups.remove(self.group_in)
|
||||
|
||||
response = self.client.post(
|
||||
reverse('motioncommentsection-list'),
|
||||
{'name': 'test_name_wfl3jlkcmlq23ucn7eiq'})
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 0)
|
||||
|
||||
def test_create_no_name(self):
|
||||
"""
|
||||
Create a section without a name. This should fail, because a name is required.
|
||||
"""
|
||||
response = self.client.post(reverse('motioncommentsection-list'), {})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 0)
|
||||
self.assertEqual(response.data['name'][0], 'This field is required.')
|
||||
|
||||
def test_create_with_groups(self):
|
||||
"""
|
||||
Create a section with name and both groups.
|
||||
"""
|
||||
response = self.client.post(
|
||||
reverse('motioncommentsection-list'),
|
||||
{
|
||||
'name': 'test_name_fg4kmFn73FhFk327f/3h',
|
||||
'read_groups_id': [2, 3],
|
||||
'write_groups_id': [3, 4],
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
comment = MotionCommentSection.objects.get()
|
||||
self.assertEqual(
|
||||
comment.name,
|
||||
'test_name_fg4kmFn73FhFk327f/3h')
|
||||
self.assertEqual(
|
||||
list(comment.read_groups.values_list('pk', flat=True)),
|
||||
[2, 3])
|
||||
self.assertEqual(
|
||||
list(comment.write_groups.values_list('pk', flat=True)),
|
||||
[3, 4])
|
||||
|
||||
def test_create_with_one_group(self):
|
||||
"""
|
||||
Create a section with a name and write_groups.
|
||||
"""
|
||||
response = self.client.post(
|
||||
reverse('motioncommentsection-list'),
|
||||
{
|
||||
'name': 'test_name_ekjfen3n)F§zn83f§Fge',
|
||||
'write_groups_id': [1, 3],
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
comment = MotionCommentSection.objects.get()
|
||||
self.assertEqual(
|
||||
comment.name,
|
||||
'test_name_ekjfen3n)F§zn83f§Fge')
|
||||
self.assertEqual(comment.read_groups.count(), 0)
|
||||
self.assertEqual(
|
||||
list(comment.write_groups.values_list('pk', flat=True)),
|
||||
[1, 3])
|
||||
|
||||
def test_create_with_non_existing_group(self):
|
||||
"""
|
||||
Create a section with some non existing groups. This should fail.
|
||||
"""
|
||||
response = self.client.post(
|
||||
reverse('motioncommentsection-list'),
|
||||
{
|
||||
'name': 'test_name_4gnUVnF§29FnH3287fhG',
|
||||
'write_groups_id': [42, 1, 8],
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 0)
|
||||
self.assertEqual(
|
||||
response.data['write_groups_id'][0],
|
||||
'Invalid pk "42" - object does not exist.')
|
||||
|
||||
def test_update(self):
|
||||
"""
|
||||
Update a section name.
|
||||
"""
|
||||
section = MotionCommentSection(name='test_name_dlfgNDf37ND(g3fNf43g')
|
||||
section.save()
|
||||
|
||||
response = self.client.put(
|
||||
reverse('motioncommentsection-detail', args=[section.pk]),
|
||||
{'name': 'test_name_ekjfen3n)F§zn83f§Fge'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
MotionCommentSection.objects.get().name,
|
||||
'test_name_ekjfen3n)F§zn83f§Fge')
|
||||
|
||||
def test_update_groups(self):
|
||||
"""
|
||||
Update one of the groups.
|
||||
"""
|
||||
section = MotionCommentSection(name='test_name_f3jFq3hShf/(fh2qlPOp')
|
||||
section.save()
|
||||
section.read_groups.add(2)
|
||||
section.write_groups.add(3)
|
||||
|
||||
response = self.client.patch(
|
||||
reverse('motioncommentsection-detail', args=[section.pk]),
|
||||
{
|
||||
'name': 'test_name_gkk3FhfhpmQMhC,Y378c',
|
||||
'read_groups_id': [2, 4],
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
comment = MotionCommentSection.objects.get()
|
||||
self.assertEqual(comment.name, 'test_name_gkk3FhfhpmQMhC,Y378c')
|
||||
self.assertEqual(
|
||||
list(comment.read_groups.values_list('pk', flat=True)),
|
||||
[2, 4])
|
||||
self.assertEqual(
|
||||
list(comment.write_groups.values_list('pk', flat=True)),
|
||||
[3])
|
||||
|
||||
def test_update_no_permission(self):
|
||||
"""
|
||||
Try to update a section without can_manage permissions.
|
||||
"""
|
||||
self.admin.groups.remove(self.group_in)
|
||||
|
||||
section = MotionCommentSection(name='test_name_wl2oxmmhe/2kd92lwPSi')
|
||||
section.save()
|
||||
|
||||
response = self.client.patch(
|
||||
reverse('motioncommentsection-list'),
|
||||
{'name': 'test_name_2slmDMwmqqcmC92mcklw'})
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
MotionCommentSection.objects.get().name,
|
||||
'test_name_wl2oxmmhe/2kd92lwPSi')
|
||||
|
||||
def test_delete(self):
|
||||
"""
|
||||
Delete a section.
|
||||
"""
|
||||
section = MotionCommentSection(name='test_name_ecMCq;ymwuZZ723kD)2k')
|
||||
section.save()
|
||||
|
||||
response = self.client.delete(reverse('motioncommentsection-detail', args=[section.pk]))
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 0)
|
||||
|
||||
def test_delete_non_existing_section(self):
|
||||
"""
|
||||
Delete a non existing section.
|
||||
"""
|
||||
response = self.client.delete(reverse('motioncommentsection-detail', args=[2]))
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 0)
|
||||
|
||||
def test_delete_with_existing_comments(self):
|
||||
"""
|
||||
Delete a section with existing comments. This should fail, because sections
|
||||
are protected.
|
||||
"""
|
||||
section = MotionCommentSection(name='test_name_ecMCq;ymwuZZ723kD)2k')
|
||||
section.save()
|
||||
|
||||
motion = Motion(
|
||||
title='test_title_SlqfMw(waso0saWMPqcZ',
|
||||
text='test_text_f30skclqS9wWF=xdfaSL')
|
||||
motion.save()
|
||||
|
||||
comment = MotionComment(
|
||||
comment='test_comment_dlkMD23m)(D9020m0/Zd',
|
||||
motion=motion,
|
||||
section=section)
|
||||
comment.save()
|
||||
|
||||
response = self.client.delete(reverse('motioncommentsection-detail', args=[section.pk]))
|
||||
self.assertTrue('test_title_SlqfMw(waso0saWMPqcZ' in response.data['detail'])
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
|
||||
def test_delete_no_permission(self):
|
||||
"""
|
||||
Try to delete a section without can_manage permissions
|
||||
"""
|
||||
self.admin.groups.remove(self.group_in)
|
||||
|
||||
section = MotionCommentSection(name='test_name_wl2oxmmhe/2kd92lwPSi')
|
||||
section.save()
|
||||
|
||||
response = self.client.delete(reverse('motioncommentsection-detail', args=[section.pk]))
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(MotionCommentSection.objects.count(), 1)
|
||||
|
||||
|
||||
class CreateMotionChangeRecommendation(TestCase):
|
||||
"""
|
||||
Tests motion change recommendation creation.
|
||||
@ -772,7 +1127,7 @@ class CreateMotionChangeRecommendation(TestCase):
|
||||
reverse('motionchangerecommendation-list'),
|
||||
{'line_from': '5',
|
||||
'line_to': '7',
|
||||
'motion_version_id': '1',
|
||||
'motion_id': '1',
|
||||
'text': '<p>New test</p>',
|
||||
'type': '0'})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
@ -785,7 +1140,7 @@ class CreateMotionChangeRecommendation(TestCase):
|
||||
reverse('motionchangerecommendation-list'),
|
||||
{'line_from': '5',
|
||||
'line_to': '7',
|
||||
'motion_version_id': '1',
|
||||
'motion_id': '1',
|
||||
'text': '<p>New test</p>',
|
||||
'type': '0'})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
@ -794,7 +1149,7 @@ class CreateMotionChangeRecommendation(TestCase):
|
||||
reverse('motionchangerecommendation-list'),
|
||||
{'line_from': '3',
|
||||
'line_to': '6',
|
||||
'motion_version_id': '1',
|
||||
'motion_id': '1',
|
||||
'text': '<p>New test</p>',
|
||||
'type': '0'})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
@ -813,7 +1168,7 @@ class CreateMotionChangeRecommendation(TestCase):
|
||||
reverse('motionchangerecommendation-list'),
|
||||
{'line_from': '5',
|
||||
'line_to': '7',
|
||||
'motion_version_id': '1',
|
||||
'motion_id': '1',
|
||||
'text': '<p>New test</p>',
|
||||
'type': '0'})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
@ -822,7 +1177,7 @@ class CreateMotionChangeRecommendation(TestCase):
|
||||
reverse('motionchangerecommendation-list'),
|
||||
{'line_from': '3',
|
||||
'line_to': '6',
|
||||
'motion_version_id': '2',
|
||||
'motion_id': '2',
|
||||
'text': '<p>New test</p>',
|
||||
'type': '0'})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
@ -480,8 +480,6 @@ class GroupUpdate(TestCase):
|
||||
'motions.can_create',
|
||||
'motions.can_manage',
|
||||
'motions.can_see',
|
||||
'motions.can_manage_comments',
|
||||
'motions.can_see_comments',
|
||||
'motions.can_support',
|
||||
'users.can_manage',
|
||||
'users.can_see_extra_data',
|
||||
|
@ -12,48 +12,6 @@ class ModelTest(TestCase):
|
||||
# Use the simple workflow
|
||||
self.workflow = Workflow.objects.get(pk=1)
|
||||
|
||||
def test_create_new_version(self):
|
||||
motion = self.motion
|
||||
self.assertEqual(motion.versions.count(), 1)
|
||||
|
||||
# new data, but no new version
|
||||
motion.title = 'new title'
|
||||
motion.save()
|
||||
self.assertEqual(motion.versions.count(), 1)
|
||||
|
||||
# new data and new version
|
||||
motion.text = 'new text'
|
||||
motion.save(use_version=motion.get_new_version())
|
||||
self.assertEqual(motion.versions.count(), 2)
|
||||
self.assertEqual(motion.title, 'new title')
|
||||
self.assertEqual(motion.text, 'new text')
|
||||
|
||||
def test_version_data(self):
|
||||
motion = Motion()
|
||||
self.assertEqual(motion.title, '')
|
||||
with self.assertRaises(AttributeError):
|
||||
self._title
|
||||
|
||||
motion.title = 'title'
|
||||
self.assertEqual(motion._title, 'title')
|
||||
|
||||
motion.text = 'text'
|
||||
self.assertEqual(motion._text, 'text')
|
||||
|
||||
motion.reason = 'reason'
|
||||
self.assertEqual(motion._reason, 'reason')
|
||||
|
||||
def test_version(self):
|
||||
motion = self.motion
|
||||
|
||||
motion.title = 'v2'
|
||||
motion.save(use_version=motion.get_new_version())
|
||||
motion.title = 'v3'
|
||||
motion.save(use_version=motion.get_new_version())
|
||||
with self.assertRaises(AttributeError):
|
||||
self._title
|
||||
self.assertEqual(motion.title, 'v3')
|
||||
|
||||
def test_supporter(self):
|
||||
self.assertFalse(self.motion.is_supporter(self.test_user))
|
||||
self.motion.supporters.add(self.test_user)
|
||||
@ -97,37 +55,6 @@ class ModelTest(TestCase):
|
||||
Motion.objects.create(title='foo', text='bar', identifier='')
|
||||
Motion.objects.create(title='foo2', text='bar2', identifier='')
|
||||
|
||||
def test_do_not_create_new_version_when_permit_old_version(self):
|
||||
motion = Motion()
|
||||
motion.title = 'foo'
|
||||
motion.text = 'bar'
|
||||
motion.save()
|
||||
first_version = motion.get_last_version()
|
||||
|
||||
motion = Motion.objects.get(pk=motion.pk)
|
||||
motion.title = 'New Title'
|
||||
motion.save(use_version=motion.get_new_version())
|
||||
new_version = motion.get_last_version()
|
||||
self.assertEqual(motion.versions.count(), 2)
|
||||
|
||||
motion.active_version = new_version
|
||||
motion.save()
|
||||
self.assertEqual(motion.versions.count(), 2)
|
||||
|
||||
motion.active_version = first_version
|
||||
motion.save(use_version=False)
|
||||
self.assertEqual(motion.versions.count(), 2)
|
||||
|
||||
def test_unicode_with_no_active_version(self):
|
||||
motion = Motion.objects.create(
|
||||
title='test_title_Koowoh1ISheemeey1air',
|
||||
text='test_text_zieFohph0doChi1Uiyoh',
|
||||
identifier='test_identifier_VohT1hu9uhiSh6ooVBFS')
|
||||
motion.active_version = None
|
||||
motion.save(update_fields=['active_version'])
|
||||
# motion.__unicode__() raised an AttributeError
|
||||
self.assertEqual(str(motion), 'test_title_Koowoh1ISheemeey1air')
|
||||
|
||||
def test_is_amendment(self):
|
||||
config['motions_amendments_enabled'] = True
|
||||
amendment = Motion.objects.create(title='amendment', parent=self.motion)
|
||||
|
@ -1,6 +1,6 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from openslides.motions.models import MotionChangeRecommendation, MotionVersion
|
||||
from openslides.motions.models import Motion, MotionChangeRecommendation
|
||||
|
||||
|
||||
class MotionChangeRecommendationTest(TestCase):
|
||||
@ -8,12 +8,12 @@ class MotionChangeRecommendationTest(TestCase):
|
||||
"""
|
||||
Tests that a change recommendation directly before another one can be created
|
||||
"""
|
||||
version = MotionVersion()
|
||||
motion = Motion()
|
||||
existing_recommendation = MotionChangeRecommendation()
|
||||
existing_recommendation.line_from = 5
|
||||
existing_recommendation.line_to = 7
|
||||
existing_recommendation.rejected = False
|
||||
existing_recommendation.motion_version = version
|
||||
existing_recommendation.motion = motion
|
||||
other_recommendations = [existing_recommendation]
|
||||
|
||||
new_recommendation1 = MotionChangeRecommendation()
|
||||
|
@ -22,33 +22,9 @@ class MotionViewSetUpdate(TestCase):
|
||||
@patch('openslides.motions.views.config')
|
||||
def test_simple_update(self, mock_config, mock_has_perm, mock_icd):
|
||||
self.request.user = 1
|
||||
self.request.data.get.return_value = versioning_mock = MagicMock()
|
||||
self.request.data.get.return_value = MagicMock()
|
||||
mock_has_perm.return_value = True
|
||||
|
||||
self.view_instance.update(self.request)
|
||||
|
||||
self.mock_serializer.save.assert_called_with(disable_versioning=versioning_mock)
|
||||
|
||||
|
||||
class MotionViewSetManageVersion(TestCase):
|
||||
"""
|
||||
Tests views of MotionViewSet to manage versions.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.request = MagicMock()
|
||||
self.view_instance = MotionViewSet()
|
||||
self.view_instance.request = self.request
|
||||
self.view_instance.get_object = get_object_mock = MagicMock()
|
||||
get_object_mock.return_value = self.mock_motion = MagicMock()
|
||||
|
||||
def test_activate_version(self):
|
||||
self.request.method = 'PUT'
|
||||
self.request.user.has_perm.return_value = True
|
||||
self.view_instance.manage_version(self.request)
|
||||
self.mock_motion.save.assert_called_with(update_fields=['active_version'])
|
||||
|
||||
def test_delete_version(self):
|
||||
self.request.method = 'DELETE'
|
||||
self.request.user.has_perm.return_value = True
|
||||
self.view_instance.manage_version(self.request)
|
||||
self.mock_motion.versions.get.return_value.delete.assert_called_with()
|
||||
self.mock_serializer.save.assert_called()
|
||||
|
Loading…
Reference in New Issue
Block a user