Run black

This commit is contained in:
Oskar Hahn 2019-01-06 16:22:33 +01:00
parent 800055a5ea
commit eddbd86d3a
186 changed files with 9061 additions and 6570 deletions

View File

@ -5,7 +5,7 @@ from parser import parser
if len(sys.argv) < 2:
args = parser.parse_args(['--help'])
args = parser.parse_args(["--help"])
else:
args = parser.parse_args()

View File

@ -2,47 +2,48 @@ from parser import command, argument, call
import yaml
import requirements
FAIL = '\033[91m'
SUCCESS = '\033[92m'
RESET = '\033[0m'
FAIL = "\033[91m"
SUCCESS = "\033[92m"
RESET = "\033[0m"
@command('check', help='Checks for pep8 errors in openslides and tests')
@command("check", help="Checks for pep8 errors in openslides and tests")
def check(args=None):
"""
Checks for pep8 and other code styling conventions.
"""
value = call('flake8 --max-line-length=150 --statistics openslides tests')
value += call('python -m mypy openslides/ tests/')
value = call("flake8 --max-line-length=150 --statistics openslides tests")
value += call("python -m mypy openslides/ tests/")
return value
@command('travis', help='Runs the code that travis does')
@command("travis", help="Runs the code that travis does")
def travis(args=None):
"""
Runs all commands that travis tests.
"""
return_codes = []
with open('.travis.yml') as f:
with open(".travis.yml") as f:
travis = yaml.load(f)
for line in travis['script']:
print('Run: {}'.format(line))
for line in travis["script"]:
print("Run: {}".format(line))
return_code = call(line)
return_codes.append(return_code)
if return_code:
print(FAIL + 'fail!\n' + RESET)
print(FAIL + "fail!\n" + RESET)
else:
print(SUCCESS + 'success!\n' + RESET)
print(SUCCESS + "success!\n" + RESET)
# Retuns True if one command exited with a different statuscode then 1
return bool(list(filter(bool, return_codes)))
@argument('-r', '--requirements', nargs='?',
default='requirements.txt')
@command('min_requirements',
help='Prints a pip line to install the minimum supported versions of '
'the requirements.')
@argument("-r", "--requirements", nargs="?", default="requirements.txt")
@command(
"min_requirements",
help="Prints a pip line to install the minimum supported versions of "
"the requirements.",
)
def min_requirements(args=None):
"""
Prints a pip install command to install the minimal supported versions of a
@ -54,6 +55,7 @@ def min_requirements(args=None):
pip install $(python make min_requirements)
"""
def get_lowest_versions(requirements_file):
with open(requirements_file) as f:
for req in requirements.parse(f):
@ -62,22 +64,20 @@ def min_requirements(args=None):
if spec == ">=":
yield "{}=={}".format(req.name, version)
print(' '.join(get_lowest_versions(args.requirements)))
print(" ".join(get_lowest_versions(args.requirements)))
@command('clean',
help='Deletes unneeded files and folders')
@command("clean", help="Deletes unneeded files and folders")
def clean(args=None):
"""
Deletes all .pyc and .orig files and empty folders.
"""
call('find -name "*.pyc" -delete')
call('find -name "*.orig" -delete')
call('find -type d -empty -delete')
call("find -type d -empty -delete")
@command('format',
help='Format code with isort and black')
@command("format", help="Format code with isort and black")
def isort(args=None):
call('isort --recursive openslides tests')
call('black --py36 openslides tests')
call("isort --recursive openslides tests")
call("black --py36 openslides tests")

View File

@ -1,7 +1,7 @@
from argparse import ArgumentParser
from subprocess import call as _call
parser = ArgumentParser(description='Development scripts for OpenSlides')
parser = ArgumentParser(description="Development scripts for OpenSlides")
subparsers = parser.add_subparsers()
@ -12,6 +12,7 @@ def command(*args, **kwargs):
The arguments to this decorator are used as arguments for the argparse
command.
"""
class decorator:
def __init__(self, func):
self.parser = subparsers.add_parser(*args, **kwargs)
@ -34,11 +35,15 @@ def argument(*args, **kwargs):
Does only work if the decorated function was decorated with the
command-decorator before.
"""
def decorator(func):
func.parser.add_argument(*args, **kwargs)
def wrapper(*func_args, **func_kwargs):
return func(*func_args, **func_kwargs)
return wrapper
return decorator

View File

@ -1,7 +1,7 @@
__author__ = 'OpenSlides Team <support@openslides.org>'
__description__ = 'Presentation and assembly system'
__version__ = '3.0-dev'
__license__ = 'MIT'
__url__ = 'https://openslides.org'
__author__ = "OpenSlides Team <support@openslides.org>"
__description__ = "Presentation and assembly system"
__version__ = "3.0-dev"
__license__ = "MIT"
__url__ = "https://openslides.org"
args = None

View File

@ -42,7 +42,7 @@ def main():
else:
# Check for unknown_args.
if unknown_args:
parser.error('Unknown arguments {}'.format(' '.join(unknown_args)))
parser.error("Unknown arguments {}".format(" ".join(unknown_args)))
# Save arguments, if one wants to access them later.
arguments.set_arguments(known_args)
@ -59,11 +59,11 @@ def get_parser():
if len(sys.argv) == 1:
# Use start subcommand if called by openslides console script without
# any other arguments.
sys.argv.append('start')
sys.argv.append("start")
# Init parser
description = 'Start script for OpenSlides.'
if 'manage.py' not in sys.argv[0]:
description = "Start script for OpenSlides."
if "manage.py" not in sys.argv[0]:
description += """
If it is called without any argument, this will be treated as
if it is called with the 'start' subcommand. That means
@ -77,109 +77,116 @@ def get_parser():
(without the two hyphen-minus characters) to list them all. Type
'%(prog)s help <subcommand>' for help on a specific subcommand.
"""
parser = ExceptionArgumentParser(
description=description,
epilog=epilog)
parser = ExceptionArgumentParser(description=description, epilog=epilog)
# Add version argument
parser.add_argument(
'--version',
action='version',
"--version",
action="version",
version=openslides.__version__,
help='Show version number and exit.')
help="Show version number and exit.",
)
# Init subparsers
subparsers = parser.add_subparsers(
dest='subcommand',
title='Available subcommands',
dest="subcommand",
title="Available subcommands",
description="Type '%s <subcommand> --help' for help on a "
"specific subcommand." % parser.prog, # type: ignore
help='You can choose only one subcommand at once.',
metavar='')
"specific subcommand." % parser.prog, # type: ignore
help="You can choose only one subcommand at once.",
metavar="",
)
# Subcommand start
start_help = (
'Setup settings and database, start webserver, launch the '
'default web browser and open the webinterface. The environment '
'variable DJANGO_SETTINGS_MODULE is ignored.')
"Setup settings and database, start webserver, launch the "
"default web browser and open the webinterface. The environment "
"variable DJANGO_SETTINGS_MODULE is ignored."
)
subcommand_start = subparsers.add_parser(
'start',
description=start_help,
help=start_help)
"start", description=start_help, help=start_help
)
subcommand_start.set_defaults(callback=start)
subcommand_start.add_argument(
'--no-browser',
action='store_true',
help='Do not launch the default web browser.')
"--no-browser",
action="store_true",
help="Do not launch the default web browser.",
)
subcommand_start.add_argument(
'--debug-email',
action='store_true',
help='Change the email backend to console output.')
"--debug-email",
action="store_true",
help="Change the email backend to console output.",
)
subcommand_start.add_argument(
'--no-template-caching',
action='store_true',
"--no-template-caching",
action="store_true",
default=False,
help='Disables caching of templates.')
help="Disables caching of templates.",
)
subcommand_start.add_argument(
'--host',
action='store',
default='0.0.0.0',
help='IP address to listen on. Default is 0.0.0.0.')
"--host",
action="store",
default="0.0.0.0",
help="IP address to listen on. Default is 0.0.0.0.",
)
subcommand_start.add_argument(
'--port',
action='store',
default='8000',
help='Port to listen on. Default is 8000.')
"--port",
action="store",
default="8000",
help="Port to listen on. Default is 8000.",
)
subcommand_start.add_argument(
'--settings_dir',
action='store',
default=None,
help='The settings directory.')
"--settings_dir", action="store", default=None, help="The settings directory."
)
subcommand_start.add_argument(
'--settings_filename',
action='store',
default='settings.py',
help='The used settings file name. The file is created, if it does not exist.')
"--settings_filename",
action="store",
default="settings.py",
help="The used settings file name. The file is created, if it does not exist.",
)
subcommand_start.add_argument(
'--local-installation',
action='store_true',
help='Store settings and user files in a local directory.')
"--local-installation",
action="store_true",
help="Store settings and user files in a local directory.",
)
# Subcommand createsettings
createsettings_help = 'Creates the settings file.'
createsettings_help = "Creates the settings file."
subcommand_createsettings = subparsers.add_parser(
'createsettings',
description=createsettings_help,
help=createsettings_help)
"createsettings", description=createsettings_help, help=createsettings_help
)
subcommand_createsettings.set_defaults(callback=createsettings)
subcommand_createsettings.add_argument(
'--settings_dir',
action='store',
"--settings_dir",
action="store",
default=None,
help='The used settings file directory. All settings files are created, even if they exist.')
help="The used settings file directory. All settings files are created, even if they exist.",
)
subcommand_createsettings.add_argument(
'--settings_filename',
action='store',
default='settings.py',
help='The used settings file name. The file is created, if it does not exist.')
"--settings_filename",
action="store",
default="settings.py",
help="The used settings file name. The file is created, if it does not exist.",
)
subcommand_createsettings.add_argument(
'--local-installation',
action='store_true',
help='Store settings and user files in a local directory.')
"--local-installation",
action="store_true",
help="Store settings and user files in a local directory.",
)
# Help text for several Django subcommands
django_subcommands = (
('backupdb', 'Backups the SQLite3 database.'),
('createsuperuser', 'Creates or resets the admin user.'),
('migrate', 'Updates database schema.'),
('runserver', 'Starts the Tornado webserver.'),
("backupdb", "Backups the SQLite3 database."),
("createsuperuser", "Creates or resets the admin user."),
("migrate", "Updates database schema."),
("runserver", "Starts the Tornado webserver."),
)
for django_subcommand, help_text in django_subcommands:
subparsers._choices_actions.append( # type: ignore
subparsers._ChoicesPseudoAction( # type: ignore
django_subcommand,
(),
help_text))
django_subcommand, (), help_text
)
)
return parser
@ -188,8 +195,10 @@ def start(args):
"""
Starts OpenSlides: Runs migrations and runs runserver.
"""
raise OpenSlidesError('The start command does not work anymore. ' +
'Please use `createsettings`, `migrate` and `runserver`.')
raise OpenSlidesError(
"The start command does not work anymore. "
+ "Please use `createsettings`, `migrate` and `runserver`."
)
settings_dir = args.settings_dir
settings_filename = args.settings_filename
local_installation = is_local_installation()
@ -212,10 +221,10 @@ def start(args):
from django.conf import settings
if args.debug_email:
settings.EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
settings.EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Migrate database
call_command('migrate')
call_command("migrate")
# Open the browser
if not args.no_browser:
@ -229,8 +238,8 @@ def start(args):
#
# Use flag --insecure to serve static files even if DEBUG is False.
call_command(
'runserver',
'{}:{}'.format(args.host, args.port),
"runserver",
"{}:{}".format(args.host, args.port),
noreload=False, # Means True, see above.
insecure=True,
)
@ -248,11 +257,14 @@ def createsettings(args):
if settings_dir is None:
settings_dir = get_local_settings_dir()
context = {
'openslides_user_data_dir': repr(os.path.join(os.getcwd(), 'personal_data', 'var')),
'debug': 'True'}
"openslides_user_data_dir": repr(
os.path.join(os.getcwd(), "personal_data", "var")
),
"debug": "True",
}
settings_path = write_settings(settings_dir, args.settings_filename, **context)
print('Settings created at %s' % settings_path)
print("Settings created at %s" % settings_path)
if __name__ == "__main__":

View File

@ -1 +1 @@
default_app_config = 'openslides.agenda.apps.AgendaAppConfig'
default_app_config = "openslides.agenda.apps.AgendaAppConfig"

View File

@ -8,14 +8,14 @@ class ItemAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Item and ItemViewSet.
"""
base_permission = 'agenda.can_see'
base_permission = "agenda.can_see"
# TODO: In the following method we use full_data['is_hidden'] and
# full_data['is_internal'] but this can be out of date.
async def get_restricted_data(
self,
full_data: List[Dict[str, Any]],
user_id: int) -> List[Dict[str, Any]]:
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Returns the restricted serialized data for the instance prepared
for the user.
@ -25,6 +25,7 @@ class ItemAccessPermissions(BaseAccessPermissions):
We remove comments for non admins/managers and a lot of fields of
internal items for users without permission to see internal items.
"""
def filtered_data(full_data, blocked_keys):
"""
Returns a new dict like full_data but with all blocked_keys removed.
@ -33,47 +34,56 @@ class ItemAccessPermissions(BaseAccessPermissions):
return {key: full_data[key] for key in whitelist}
# Parse data.
if full_data and await async_has_perm(user_id, 'agenda.can_see'):
if await async_has_perm(user_id, 'agenda.can_manage') and await async_has_perm(user_id, 'agenda.can_see_internal_items'):
if full_data and await async_has_perm(user_id, "agenda.can_see"):
if await async_has_perm(
user_id, "agenda.can_manage"
) and await async_has_perm(user_id, "agenda.can_see_internal_items"):
# Managers with special permission can see everything.
data = full_data
elif await async_has_perm(user_id, 'agenda.can_see_internal_items'):
elif await async_has_perm(user_id, "agenda.can_see_internal_items"):
# Non managers with special permission can see everything but
# comments and hidden items.
data = [full for full in full_data if not full['is_hidden']] # filter hidden items
blocked_keys = ('comment',)
data = [filtered_data(full, blocked_keys) for full in data] # remove blocked_keys
data = [
full for full in full_data if not full["is_hidden"]
] # filter hidden items
blocked_keys = ("comment",)
data = [
filtered_data(full, blocked_keys) for full in data
] # remove blocked_keys
else:
# Users without special permission for internal items.
# In internal and hidden case managers and non managers see only some fields
# so that list of speakers is provided regardless. Hidden items can only be seen by managers.
# We know that full_data has at least one entry which can be used to parse the keys.
blocked_keys_internal_hidden_case = set(full_data[0].keys()) - set((
'id',
'title',
'speakers',
'speaker_list_closed',
'content_object'))
blocked_keys_internal_hidden_case = set(full_data[0].keys()) - set(
("id", "title", "speakers", "speaker_list_closed", "content_object")
)
# In non internal case managers see everything and non managers see
# everything but comments.
if await async_has_perm(user_id, 'agenda.can_manage'):
if await async_has_perm(user_id, "agenda.can_manage"):
blocked_keys_non_internal_hidden_case: Iterable[str] = []
can_see_hidden = True
else:
blocked_keys_non_internal_hidden_case = ('comment',)
blocked_keys_non_internal_hidden_case = ("comment",)
can_see_hidden = False
data = []
for full in full_data:
if full['is_hidden'] and can_see_hidden:
if full["is_hidden"] and can_see_hidden:
# Same filtering for internal and hidden items
data.append(filtered_data(full, blocked_keys_internal_hidden_case))
elif full['is_internal']:
data.append(filtered_data(full, blocked_keys_internal_hidden_case))
data.append(
filtered_data(full, blocked_keys_internal_hidden_case)
)
elif full["is_internal"]:
data.append(
filtered_data(full, blocked_keys_internal_hidden_case)
)
else: # agenda item
data.append(filtered_data(full, blocked_keys_non_internal_hidden_case))
data.append(
filtered_data(full, blocked_keys_non_internal_hidden_case)
)
else:
data = []

View File

@ -6,8 +6,8 @@ from ..utils.projector import register_projector_elements
class AgendaAppConfig(AppConfig):
name = 'openslides.agenda'
verbose_name = 'OpenSlides Agenda'
name = "openslides.agenda"
verbose_name = "OpenSlides Agenda"
angular_site_module = True
angular_projector_module = True
@ -20,7 +20,8 @@ class AgendaAppConfig(AppConfig):
from .signals import (
get_permission_change_data,
listen_to_related_object_post_delete,
listen_to_related_object_post_save)
listen_to_related_object_post_save,
)
from .views import ItemViewSet
from . import serializers # noqa
from ..utils.access_permissions import required_user
@ -31,22 +32,27 @@ class AgendaAppConfig(AppConfig):
# Connect signals.
post_save.connect(
listen_to_related_object_post_save,
dispatch_uid='listen_to_related_object_post_save')
dispatch_uid="listen_to_related_object_post_save",
)
pre_delete.connect(
listen_to_related_object_post_delete,
dispatch_uid='listen_to_related_object_post_delete')
dispatch_uid="listen_to_related_object_post_delete",
)
permission_change.connect(
get_permission_change_data,
dispatch_uid='agenda_get_permission_change_data')
get_permission_change_data, dispatch_uid="agenda_get_permission_change_data"
)
# Register viewsets.
router.register(self.get_model('Item').get_collection_string(), ItemViewSet)
router.register(self.get_model("Item").get_collection_string(), ItemViewSet)
# register required_users
required_user.add_collection_string(self.get_model('Item').get_collection_string(), required_users)
required_user.add_collection_string(
self.get_model("Item").get_collection_string(), required_users
)
def get_config_variables(self):
from .config_variables import get_config_variables
return get_config_variables()
def get_startup_elements(self):
@ -54,11 +60,11 @@ class AgendaAppConfig(AppConfig):
Yields all Cachables required on startup i. e. opening the websocket
connection.
"""
yield self.get_model('Item')
yield self.get_model("Item")
def required_users(element: Dict[str, Any]) -> Set[int]:
"""
Returns all user ids that are displayed as speaker in the given element.
"""
return set(speaker['user_id'] for speaker in element['speakers'])
return set(speaker["user_id"] for speaker in element["speakers"])

View File

@ -10,97 +10,108 @@ def get_config_variables():
It has to be evaluated during app loading (see apps.py).
"""
yield ConfigVariable(
name='agenda_enable_numbering',
label='Enable numbering for agenda items',
input_type='boolean',
name="agenda_enable_numbering",
label="Enable numbering for agenda items",
input_type="boolean",
default_value=True,
weight=200,
group='Agenda',
subgroup='General')
group="Agenda",
subgroup="General",
)
yield ConfigVariable(
name='agenda_number_prefix',
default_value='',
label='Numbering prefix for agenda items',
help_text='This prefix will be set if you run the automatic agenda numbering.',
name="agenda_number_prefix",
default_value="",
label="Numbering prefix for agenda items",
help_text="This prefix will be set if you run the automatic agenda numbering.",
weight=210,
group='Agenda',
subgroup='General',
validators=(MaxLengthValidator(20),))
group="Agenda",
subgroup="General",
validators=(MaxLengthValidator(20),),
)
yield ConfigVariable(
name='agenda_numeral_system',
default_value='arabic',
input_type='choice',
label='Numeral system for agenda items',
name="agenda_numeral_system",
default_value="arabic",
input_type="choice",
label="Numeral system for agenda items",
choices=(
{'value': 'arabic', 'display_name': 'Arabic'},
{'value': 'roman', 'display_name': 'Roman'}),
{"value": "arabic", "display_name": "Arabic"},
{"value": "roman", "display_name": "Roman"},
),
weight=215,
group='Agenda',
subgroup='General')
group="Agenda",
subgroup="General",
)
yield ConfigVariable(
name='agenda_start_event_date_time',
name="agenda_start_event_date_time",
default_value=None,
input_type='datetimepicker',
label='Begin of event',
help_text='Input format: DD.MM.YYYY HH:MM',
input_type="datetimepicker",
label="Begin of event",
help_text="Input format: DD.MM.YYYY HH:MM",
weight=220,
group='Agenda',
subgroup='General')
group="Agenda",
subgroup="General",
)
yield ConfigVariable(
name='agenda_hide_internal_items_on_projector',
name="agenda_hide_internal_items_on_projector",
default_value=True,
input_type='boolean',
label='Hide internal items when projecting subitems',
input_type="boolean",
label="Hide internal items when projecting subitems",
weight=225,
group='Agenda',
subgroup='General')
group="Agenda",
subgroup="General",
)
yield ConfigVariable(
name='agenda_new_items_default_visibility',
default_value='2',
input_type='choice',
name="agenda_new_items_default_visibility",
default_value="2",
input_type="choice",
choices=(
{'value': '1', 'display_name': 'Public item'},
{'value': '2', 'display_name': 'Internal item'},
{'value': '3', 'display_name': 'Hidden item'}),
label='Default visibility for new agenda items (except topics)',
{"value": "1", "display_name": "Public item"},
{"value": "2", "display_name": "Internal item"},
{"value": "3", "display_name": "Hidden item"},
),
label="Default visibility for new agenda items (except topics)",
weight=227,
group='Agenda',
subgroup='General')
group="Agenda",
subgroup="General",
)
# List of speakers
yield ConfigVariable(
name='agenda_show_last_speakers',
name="agenda_show_last_speakers",
default_value=1,
input_type='integer',
label='Number of last speakers to be shown on the projector',
input_type="integer",
label="Number of last speakers to be shown on the projector",
weight=230,
group='Agenda',
subgroup='List of speakers',
validators=(MinValueValidator(0),))
group="Agenda",
subgroup="List of speakers",
validators=(MinValueValidator(0),),
)
yield ConfigVariable(
name='agenda_countdown_warning_time',
name="agenda_countdown_warning_time",
default_value=0,
input_type='integer',
label='Show orange countdown in the last x seconds of speaking time',
help_text='Enter duration in seconds. Choose 0 to disable warning color.',
input_type="integer",
label="Show orange countdown in the last x seconds of speaking time",
help_text="Enter duration in seconds. Choose 0 to disable warning color.",
weight=235,
group='Agenda',
subgroup='List of speakers',
validators=(MinValueValidator(0),))
group="Agenda",
subgroup="List of speakers",
validators=(MinValueValidator(0),),
)
yield ConfigVariable(
name='agenda_couple_countdown_and_speakers',
name="agenda_couple_countdown_and_speakers",
default_value=False,
input_type='boolean',
label='Couple countdown with the list of speakers',
help_text='[Begin speech] starts the countdown, [End speech] stops the countdown.',
input_type="boolean",
label="Couple countdown with the list of speakers",
help_text="[Begin speech] starts the countdown, [End speech] stops the countdown.",
weight=240,
group='Agenda',
subgroup='List of speakers')
group="Agenda",
subgroup="List of speakers",
)

View File

@ -14,55 +14,109 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Item',
name="Item",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item_number', models.CharField(blank=True, max_length=255)),
('comment', models.TextField(blank=True, null=True)),
('closed', models.BooleanField(default=False)),
('type', models.IntegerField(choices=[(1, 'Agenda item'), (2, 'Hidden item')], default=2)),
('duration', models.CharField(blank=True, max_length=5, null=True)),
('weight', models.IntegerField(default=10000)),
('object_id', models.PositiveIntegerField(blank=True, null=True)),
('speaker_list_closed', models.BooleanField(default=False)),
('content_type', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.ContentType')),
('parent', models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='agenda.Item')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("item_number", models.CharField(blank=True, max_length=255)),
("comment", models.TextField(blank=True, null=True)),
("closed", models.BooleanField(default=False)),
(
"type",
models.IntegerField(
choices=[(1, "Agenda item"), (2, "Hidden item")], default=2
),
),
("duration", models.CharField(blank=True, max_length=5, null=True)),
("weight", models.IntegerField(default=10000)),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
("speaker_list_closed", models.BooleanField(default=False)),
(
"content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="contenttypes.ContentType",
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="agenda.Item",
),
),
],
options={
'permissions': (
('can_see', 'Can see agenda'),
('can_manage', 'Can manage agenda'),
('can_see_hidden_items', 'Can see hidden items and time scheduling of agenda')),
'default_permissions': (),
"permissions": (
("can_see", "Can see agenda"),
("can_manage", "Can manage agenda"),
(
"can_see_hidden_items",
"Can see hidden items and time scheduling of agenda",
),
),
"default_permissions": (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='Speaker',
name="Speaker",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('begin_time', models.DateTimeField(null=True)),
('end_time', models.DateTimeField(null=True)),
('weight', models.IntegerField(null=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='speakers', to='agenda.Item')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("begin_time", models.DateTimeField(null=True)),
("end_time", models.DateTimeField(null=True)),
("weight", models.IntegerField(null=True)),
(
"item",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="speakers",
to="agenda.Item",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'permissions': (('can_be_speaker', 'Can put oneself on the list of speakers'),),
'default_permissions': (),
"permissions": (
("can_be_speaker", "Can put oneself on the list of speakers"),
),
"default_permissions": (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AlterUniqueTogether(
name='item',
unique_together=set([('content_type', 'object_id')]),
name="item", unique_together=set([("content_type", "object_id")])
),
]

View File

@ -11,7 +11,7 @@ def convert_duration(apps, schema_editor):
IntegerField. It uses the temporary field for proper renaming the field
in the end.
"""
Item = apps.get_model('agenda', 'Item')
Item = apps.get_model("agenda", "Item")
for item in Item.objects.all():
duration = item.duration
item.duration_tmp = None
@ -20,7 +20,7 @@ def convert_duration(apps, schema_editor):
item.duration_tmp = int(duration)
elif isinstance(duration, str):
# Assuming format (h)h:(m)m. If not, new value is None.
split = duration.split(':')
split = duration.split(":")
if len(split) == 2 and is_int(split[0]) and is_int(split[1]):
# Calculate new duration: hours * 60 + minutes.
item.duration_tmp = int(split[0]) * 60 + int(split[1])
@ -41,26 +41,17 @@ def is_int(s):
class Migration(migrations.Migration):
dependencies = [
('agenda', '0001_initial'),
]
dependencies = [("agenda", "0001_initial")]
operations = [
migrations.AddField(
model_name='item',
name='duration_tmp',
model_name="item",
name="duration_tmp",
field=models.IntegerField(blank=True, null=True),
),
migrations.RunPython(
convert_duration
),
migrations.RemoveField(
model_name='item',
name='duration',
),
migrations.RunPython(convert_duration),
migrations.RemoveField(model_name="item", name="duration"),
migrations.RenameField(
model_name='item',
old_name='duration_tmp',
new_name='duration',
model_name="item", old_name="duration_tmp", new_name="duration"
),
]

View File

@ -11,24 +11,31 @@ from openslides.utils.migrations import (
class Migration(migrations.Migration):
dependencies = [
('agenda', '0002_item_duration'),
]
dependencies = [("agenda", "0002_item_duration")]
operations = [
migrations.AlterModelOptions(
name='item',
name="item",
options={
'default_permissions': (),
'permissions': (
('can_see', 'Can see agenda'),
('can_manage', 'Can manage agenda'),
('can_manage_list_of_speakers', 'Can manage list of speakers'),
('can_see_hidden_items', 'Can see hidden items and time scheduling of agenda')
)
"default_permissions": (),
"permissions": (
("can_see", "Can see agenda"),
("can_manage", "Can manage agenda"),
("can_manage_list_of_speakers", "Can manage list of speakers"),
(
"can_see_hidden_items",
"Can see hidden items and time scheduling of agenda",
),
),
},
),
migrations.RunPython(add_permission_to_groups_based_on_existing_permission(
'can_manage', 'item', 'agenda', 'can_manage_list_of_speakers', 'Can manage list of speakers'
)),
migrations.RunPython(
add_permission_to_groups_based_on_existing_permission(
"can_manage",
"item",
"agenda",
"can_manage_list_of_speakers",
"Can manage list of speakers",
)
),
]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agenda', '0003_auto_20170818_1202'),
]
dependencies = [("agenda", "0003_auto_20170818_1202")]
operations = [
migrations.AddField(
model_name='speaker',
name='marked',
model_name="speaker",
name="marked",
field=models.BooleanField(default=False),
),
)
]

View File

@ -11,44 +11,47 @@ from openslides.utils.migrations import (
def delete_old_can_see_hidden_permission(apps, schema_editor):
perm = Permission.objects.filter(codename='can_see_hidden_items')
perm = Permission.objects.filter(codename="can_see_hidden_items")
if len(perm):
perm = perm.delete()
class Migration(migrations.Migration):
dependencies = [
('agenda', '0004_speaker_marked'),
]
dependencies = [("agenda", "0004_speaker_marked")]
operations = [
migrations.AlterModelOptions(
name='item',
name="item",
options={
'default_permissions': (),
'permissions': (
('can_see', 'Can see agenda'),
('can_manage', 'Can manage agenda'),
('can_manage_list_of_speakers', 'Can manage list of speakers'),
('can_see_internal_items', 'Can see internal items and time scheduling of agenda')
)
"default_permissions": (),
"permissions": (
("can_see", "Can see agenda"),
("can_manage", "Can manage agenda"),
("can_manage_list_of_speakers", "Can manage list of speakers"),
(
"can_see_internal_items",
"Can see internal items and time scheduling of agenda",
),
),
},
),
migrations.AlterField(
model_name='item',
name='type',
model_name="item",
name="type",
field=models.IntegerField(
choices=[
(1, 'Agenda item'),
(2, 'Internal item'),
(3, 'Hidden item')
],
default=3
choices=[(1, "Agenda item"), (2, "Internal item"), (3, "Hidden item")],
default=3,
),
),
migrations.RunPython(add_permission_to_groups_based_on_existing_permission(
'can_see_hidden_items', 'item', 'agenda', 'can_see_internal_items', 'Can see internal items and time scheduling of agenda'
)),
migrations.RunPython(
add_permission_to_groups_based_on_existing_permission(
"can_see_hidden_items",
"item",
"agenda",
"can_see_internal_items",
"Can see internal items and time scheduling of agenda",
)
),
migrations.RunPython(delete_old_can_see_hidden_permission),
]

View File

@ -24,13 +24,14 @@ class ItemManager(models.Manager):
Customized model manager with special methods for agenda tree and
numbering.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all items. In the background all
speakers and related items (topics, motions, assignments) are
prefetched from the database.
"""
return self.get_queryset().prefetch_related('speakers', 'content_object')
return self.get_queryset().prefetch_related("speakers", "content_object")
def get_only_non_public_items(self):
"""
@ -45,14 +46,17 @@ class ItemManager(models.Manager):
Generator that yields a list of items and their children.
"""
for item in items:
if parent_is_not_public or item.type in (item.INTERNAL_ITEM, item.HIDDEN_ITEM):
if parent_is_not_public or item.type in (
item.INTERNAL_ITEM,
item.HIDDEN_ITEM,
):
item_is_not_public = True
yield item
else:
item_is_not_public = False
yield from yield_items(
item_children[item.pk],
parent_is_not_public=item_is_not_public)
item_children[item.pk], parent_is_not_public=item_is_not_public
)
yield from yield_items(root_items)
@ -64,7 +68,7 @@ class ItemManager(models.Manager):
If only_item_type is given, the tree hides items with other types and
all of their children.
"""
queryset = self.order_by('weight')
queryset = self.order_by("weight")
item_children: Dict[int, List[Item]] = defaultdict(list)
root_items = []
for item in queryset:
@ -88,7 +92,9 @@ class ItemManager(models.Manager):
If include_content is True, the yielded dictonaries have no key 'id'
but a key 'item' with the entire object.
"""
root_items, item_children = self.get_root_and_children(only_item_type=only_item_type)
root_items, item_children = self.get_root_and_children(
only_item_type=only_item_type
)
def get_children(items):
"""
@ -98,7 +104,9 @@ class ItemManager(models.Manager):
if include_content:
yield dict(item=item, children=get_children(item_children[item.pk]))
else:
yield dict(id=item.pk, children=get_children(item_children[item.pk]))
yield dict(
id=item.pk, children=get_children(item_children[item.pk])
)
yield from get_children(root_items)
@ -110,6 +118,7 @@ class ItemManager(models.Manager):
The tree has to be a nested object. For example:
[{"id": 1}, {"id": 2, "children": [{"id": 3}]}]
"""
def walk_items(tree, parent=None):
"""
Generator that returns each item in the tree as tuple.
@ -118,15 +127,17 @@ class ItemManager(models.Manager):
weight of the item.
"""
for weight, element in enumerate(tree):
yield (element['id'], parent, weight)
yield from walk_items(element.get('children', []), element['id'])
yield (element["id"], parent, weight)
yield from walk_items(element.get("children", []), element["id"])
touched_items: Set[int] = set()
db_items = dict((item.pk, item) for item in Item.objects.all())
for item_id, parent_id, weight in walk_items(tree):
# Check that the item is only once in the tree to prevent invalid trees
if item_id in touched_items:
raise ValueError("Item {} is more then once in the tree.".format(item_id))
raise ValueError(
"Item {} is more then once in the tree.".format(item_id)
)
touched_items.add(item_id)
try:
@ -143,36 +154,40 @@ class ItemManager(models.Manager):
db_item.save()
@transaction.atomic
def number_all(self, numeral_system='arabic'):
def number_all(self, numeral_system="arabic"):
"""
Auto numbering of the agenda according to the numeral_system. Manually
added item numbers will be overwritten.
"""
def walk_tree(tree, number=None):
for index, tree_element in enumerate(tree):
# Calculate number of visable agenda items.
if numeral_system == 'roman' and number is None:
if numeral_system == "roman" and number is None:
item_number = to_roman(index + 1)
else:
item_number = str(index + 1)
if number is not None:
item_number = '.'.join((number, item_number))
item_number = ".".join((number, item_number))
# Add prefix.
if config['agenda_number_prefix']:
item_number_tmp = "%s %s" % (config['agenda_number_prefix'], item_number)
if config["agenda_number_prefix"]:
item_number_tmp = "%s %s" % (
config["agenda_number_prefix"],
item_number,
)
else:
item_number_tmp = item_number
# Save the new value and go down the tree.
tree_element['item'].item_number = item_number_tmp
tree_element['item'].save()
walk_tree(tree_element['children'], item_number)
tree_element["item"].item_number = item_number_tmp
tree_element["item"].save()
walk_tree(tree_element["children"], item_number)
# Start numbering visable agenda items.
walk_tree(self.get_tree(only_item_type=Item.AGENDA_ITEM, include_content=True))
# Reset number of hidden items.
for item in self.get_only_non_public_items():
item.item_number = ''
item.item_number = ""
item.save()
@ -180,18 +195,20 @@ class Item(RESTModelMixin, models.Model):
"""
An Agenda Item
"""
access_permissions = ItemAccessPermissions()
objects = ItemManager()
can_see_permission = 'agenda.can_see'
can_see_permission = "agenda.can_see"
AGENDA_ITEM = 1
INTERNAL_ITEM = 2
HIDDEN_ITEM = 3
ITEM_TYPE = (
(AGENDA_ITEM, ugettext_lazy('Agenda item')),
(INTERNAL_ITEM, ugettext_lazy('Internal item')),
(HIDDEN_ITEM, ugettext_lazy('Hidden item')))
(AGENDA_ITEM, ugettext_lazy("Agenda item")),
(INTERNAL_ITEM, ugettext_lazy("Internal item")),
(HIDDEN_ITEM, ugettext_lazy("Hidden item")),
)
item_number = models.CharField(blank=True, max_length=255)
"""
@ -208,9 +225,7 @@ class Item(RESTModelMixin, models.Model):
Flag, if the item is finished.
"""
type = models.IntegerField(
choices=ITEM_TYPE,
default=HIDDEN_ITEM)
type = models.IntegerField(choices=ITEM_TYPE, default=HIDDEN_ITEM)
"""
Type of the agenda item.
@ -223,11 +238,12 @@ class Item(RESTModelMixin, models.Model):
"""
parent = models.ForeignKey(
'self',
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='children')
related_name="children",
)
"""
The parent item in the agenda tree.
"""
@ -238,10 +254,8 @@ class Item(RESTModelMixin, models.Model):
"""
content_type = models.ForeignKey(
ContentType,
on_delete=models.SET_NULL,
null=True,
blank=True)
ContentType, on_delete=models.SET_NULL, null=True, blank=True
)
"""
Field for generic relation to a related object. Type of the object.
"""
@ -256,8 +270,7 @@ class Item(RESTModelMixin, models.Model):
Field for generic relation to a related object. General field to the related object.
"""
speaker_list_closed = models.BooleanField(
default=False)
speaker_list_closed = models.BooleanField(default=False)
"""
True, if the list of speakers is closed.
"""
@ -265,11 +278,15 @@ class Item(RESTModelMixin, models.Model):
class Meta:
default_permissions = ()
permissions = (
('can_see', 'Can see agenda'),
('can_manage', 'Can manage agenda'),
('can_manage_list_of_speakers', 'Can manage list of speakers'),
('can_see_internal_items', 'Can see internal items and time scheduling of agenda'))
unique_together = ('content_type', 'object_id')
("can_see", "Can see agenda"),
("can_manage", "Can manage agenda"),
("can_manage_list_of_speakers", "Can manage list of speakers"),
(
"can_see_internal_items",
"Can see internal items and time scheduling of agenda",
),
)
unique_together = ("content_type", "object_id")
def __str__(self):
return self.title
@ -280,10 +297,11 @@ class Item(RESTModelMixin, models.Model):
list of speakers projector element is disabled.
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='agenda/list-of-speakers',
id=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore
skip_autoupdate=skip_autoupdate, name="agenda/list-of-speakers", id=self.pk
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
@property
def title(self):
@ -293,8 +311,10 @@ class Item(RESTModelMixin, models.Model):
try:
return self.content_object.get_agenda_title()
except AttributeError:
raise NotImplementedError('You have to provide a get_agenda_title '
'method on your related model.')
raise NotImplementedError(
"You have to provide a get_agenda_title "
"method on your related model."
)
@property
def title_with_type(self):
@ -304,8 +324,10 @@ class Item(RESTModelMixin, models.Model):
try:
return self.content_object.get_agenda_title_with_type()
except AttributeError:
raise NotImplementedError('You have to provide a get_agenda_title_with_type '
'method on your related model.')
raise NotImplementedError(
"You have to provide a get_agenda_title_with_type "
"method on your related model."
)
def is_internal(self):
"""
@ -314,8 +336,9 @@ class Item(RESTModelMixin, models.Model):
Attention! This executes one query for each ancestor of the item.
"""
return (self.type == self.INTERNAL_ITEM or
(self.parent is not None and self.parent.is_internal()))
return self.type == self.INTERNAL_ITEM or (
self.parent is not None and self.parent.is_internal()
)
def is_hidden(self):
"""
@ -324,15 +347,16 @@ class Item(RESTModelMixin, models.Model):
Attention! This executes one query for each ancestor of the item.
"""
return (self.type == self.HIDDEN_ITEM or
(self.parent is not None and self.parent.is_hidden()))
return self.type == self.HIDDEN_ITEM or (
self.parent is not None and self.parent.is_hidden()
)
def get_next_speaker(self):
"""
Returns the speaker object of the speaker who is next.
"""
try:
return self.speakers.filter(begin_time=None).order_by('weight')[0]
return self.speakers.filter(begin_time=None).order_by("weight")[0]
except IndexError:
# The list of speakers is empty.
return None
@ -342,6 +366,7 @@ class SpeakerManager(models.Manager):
"""
Manager for Speaker model. Provides a customized add method.
"""
def add(self, user, item, skip_autoupdate=False):
"""
Customized manager method to prevent anonymous users to be on the
@ -350,12 +375,15 @@ class SpeakerManager(models.Manager):
"""
if self.filter(user=user, item=item, begin_time=None).exists():
raise OpenSlidesError(
_('{user} is already on the list of speakers.').format(user=user))
_("{user} is already on the list of speakers.").format(user=user)
)
if isinstance(user, AnonymousUser):
raise OpenSlidesError(
_('An anonymous user can not be on lists of speakers.'))
weight = (self.filter(item=item).aggregate(
models.Max('weight'))['weight__max'] or 0)
_("An anonymous user can not be on lists of speakers.")
)
weight = (
self.filter(item=item).aggregate(models.Max("weight"))["weight__max"] or 0
)
speaker = self.model(item=item, user=user, weight=weight + 1)
speaker.save(force_insert=True, skip_autoupdate=skip_autoupdate)
return speaker
@ -368,17 +396,12 @@ class Speaker(RESTModelMixin, models.Model):
objects = SpeakerManager()
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
"""
ForeinKey to the user who speaks.
"""
item = models.ForeignKey(
Item,
on_delete=models.CASCADE,
related_name='speakers')
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="speakers")
"""
ForeinKey to the agenda item to which the user want to speak.
"""
@ -405,9 +428,7 @@ class Speaker(RESTModelMixin, models.Model):
class Meta:
default_permissions = ()
permissions = (
('can_be_speaker', 'Can put oneself on the list of speakers'),
)
permissions = (("can_be_speaker", "Can put oneself on the list of speakers"),)
def __str__(self):
return str(self.user)
@ -420,8 +441,11 @@ class Speaker(RESTModelMixin, models.Model):
speaking, end his speech.
"""
try:
current_speaker = (Speaker.objects.filter(item=self.item, end_time=None)
.exclude(begin_time=None).get())
current_speaker = (
Speaker.objects.filter(item=self.item, end_time=None)
.exclude(begin_time=None)
.get()
)
except Speaker.DoesNotExist:
pass
else:
@ -431,15 +455,21 @@ class Speaker(RESTModelMixin, models.Model):
self.weight = None
self.begin_time = timezone.now()
self.save() # Here, the item is saved and causes an autoupdate.
if config['agenda_couple_countdown_and_speakers']:
countdown, created = Countdown.objects.get_or_create(pk=1, defaults={
'default_time': config['projector_default_countdown'],
'countdown_time': config['projector_default_countdown']})
if config["agenda_couple_countdown_and_speakers"]:
countdown, created = Countdown.objects.get_or_create(
pk=1,
defaults={
"default_time": config["projector_default_countdown"],
"countdown_time": config["projector_default_countdown"],
},
)
if not created:
countdown.control(action='reset', skip_autoupdate=True)
countdown.control(action='start', skip_autoupdate=True)
countdown.control(action="reset", skip_autoupdate=True)
countdown.control(action="start", skip_autoupdate=True)
inform_changed_data(countdown) # Here, the autoupdate for the countdown is triggered.
inform_changed_data(
countdown
) # Here, the autoupdate for the countdown is triggered.
def end_speech(self, skip_autoupdate=False):
"""
@ -447,13 +477,13 @@ class Speaker(RESTModelMixin, models.Model):
"""
self.end_time = timezone.now()
self.save(skip_autoupdate=skip_autoupdate)
if config['agenda_couple_countdown_and_speakers']:
if config["agenda_couple_countdown_and_speakers"]:
try:
countdown = Countdown.objects.get(pk=1)
except Countdown.DoesNotExist:
pass # Do not create a new countdown on stop action
else:
countdown.control(action='reset', skip_autoupdate=skip_autoupdate)
countdown.control(action="reset", skip_autoupdate=skip_autoupdate)
def get_root_rest_element(self):
"""

View File

@ -16,14 +16,15 @@ class ItemListSlide(ProjectorElement):
Additionally set 'tree' to True to get also children of children.
"""
name = 'agenda/item-list'
name = "agenda/item-list"
def check_data(self):
pk = self.config_entry.get('id')
pk = self.config_entry.get("id")
if pk is not None:
# Children slide.
if not Item.objects.filter(pk=pk).exists():
raise ProjectorException('Item does not exist.')
raise ProjectorException("Item does not exist.")
class ListOfSpeakersSlide(ProjectorElement):
@ -31,21 +32,23 @@ class ListOfSpeakersSlide(ProjectorElement):
Slide definitions for Item model.
This is only for list of speakers slide. You have to set 'id'.
"""
name = 'agenda/list-of-speakers'
name = "agenda/list-of-speakers"
def check_data(self):
if not Item.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('Item does not exist.')
if not Item.objects.filter(pk=self.config_entry.get("id")).exists():
raise ProjectorException("Item does not exist.")
def update_data(self):
return {'agenda_item_id': self.config_entry.get('id')}
return {"agenda_item_id": self.config_entry.get("id")}
class CurrentListOfSpeakersSlide(ProjectorElement):
"""
Slide for the current list of speakers.
"""
name = 'agenda/current-list-of-speakers'
name = "agenda/current-list-of-speakers"
def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]:

View File

@ -7,16 +7,17 @@ class SpeakerSerializer(ModelSerializer):
"""
Serializer for agenda.models.Speaker objects.
"""
class Meta:
model = Speaker
fields = (
'id',
'user',
'begin_time',
'end_time',
'weight',
'marked',
'item', # js-data needs the item-id in the nested object to define relations.
"id",
"user",
"begin_time",
"end_time",
"weight",
"marked",
"item", # js-data needs the item-id in the nested object to define relations.
)
@ -24,36 +25,39 @@ class RelatedItemRelatedField(RelatedField):
"""
A custom field to use for the content_object generic relationship.
"""
def to_representation(self, value):
"""
Returns info concerning the related object extracted from the api URL
of this object.
"""
return {'collection': value.get_collection_string(), 'id': value.get_rest_pk()}
return {"collection": value.get_collection_string(), "id": value.get_rest_pk()}
class ItemSerializer(ModelSerializer):
"""
Serializer for agenda.models.Item objects.
"""
content_object = RelatedItemRelatedField(read_only=True)
speakers = SpeakerSerializer(many=True, read_only=True)
class Meta:
model = Item
fields = (
'id',
'item_number',
'title',
'title_with_type',
'comment',
'closed',
'type',
'is_internal',
'is_hidden',
'duration',
'speakers',
'speaker_list_closed',
'content_object',
'weight',
'parent',)
"id",
"item_number",
"title",
"title_with_type",
"comment",
"closed",
"type",
"is_internal",
"is_hidden",
"duration",
"speakers",
"speaker_list_closed",
"content_object",
"weight",
"parent",
)

View File

@ -16,18 +16,18 @@ def listen_to_related_object_post_save(sender, instance, created, **kwargs):
Do not run caching and autoupdate if the instance has a key
skip_autoupdate in the agenda_item_update_information container.
"""
if hasattr(instance, 'get_agenda_title'):
if hasattr(instance, "get_agenda_title"):
if created:
attrs = {}
for attr in ('type', 'parent_id', 'comment', 'duration', 'weight'):
for attr in ("type", "parent_id", "comment", "duration", "weight"):
if instance.agenda_item_update_information.get(attr):
attrs[attr] = instance.agenda_item_update_information.get(attr)
Item.objects.create(content_object=instance, **attrs)
# If the object is created, the related_object has to be sent again.
if not instance.agenda_item_update_information.get('skip_autoupdate'):
if not instance.agenda_item_update_information.get("skip_autoupdate"):
inform_changed_data(instance)
elif not instance.agenda_item_update_information.get('skip_autoupdate'):
elif not instance.agenda_item_update_information.get("skip_autoupdate"):
# If the object has changed, then also the agenda item has to be sent.
inform_changed_data(instance.agenda_item)
@ -37,7 +37,7 @@ def listen_to_related_object_post_delete(sender, instance, **kwargs):
Receiver function to delete agenda items. It is connected to the signal
django.db.models.signals.post_delete during app loading.
"""
if hasattr(instance, 'get_agenda_title'):
if hasattr(instance, "get_agenda_title"):
content_type = ContentType.objects.get_for_model(instance)
try:
# Attention: This delete() call is also necessary to remove
@ -53,10 +53,12 @@ def get_permission_change_data(sender, permissions, **kwargs):
Yields all necessary collections if 'agenda.can_see' or
'agenda.can_see_internal_items' permissions changes.
"""
agenda_app = apps.get_app_config(app_label='agenda')
agenda_app = apps.get_app_config(app_label="agenda")
for permission in permissions:
# There could be only one 'agenda.can_see' and then we want to return data.
if (permission.content_type.app_label == agenda_app.label
and permission.codename in ('can_see', 'can_see_internal_items')):
if (
permission.content_type.app_label == agenda_app.label
and permission.codename in ("can_see", "can_see_internal_items")
):
yield from agenda_app.get_startup_elements()
break

View File

@ -24,12 +24,14 @@ from .models import Item, Speaker
# Viewsets for the REST API
class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
"""
API endpoint for agenda items.
There are some views, see check_view_permissions.
"""
access_permissions = ItemAccessPermissions()
queryset = Item.objects.all()
@ -37,22 +39,26 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ('metadata', 'manage_speaker', 'tree'):
result = has_perm(self.request.user, 'agenda.can_see')
elif self.action in ("metadata", "manage_speaker", "tree"):
result = has_perm(self.request.user, "agenda.can_see")
# For manage_speaker and tree requests the rest of the check is
# done in the specific method. See below.
elif self.action in ('partial_update', 'update', 'sort', 'assign'):
result = (has_perm(self.request.user, 'agenda.can_see') and
has_perm(self.request.user, 'agenda.can_see_internal_items') and
has_perm(self.request.user, 'agenda.can_manage'))
elif self.action in ('speak', 'sort_speakers'):
result = (has_perm(self.request.user, 'agenda.can_see') and
has_perm(self.request.user, 'agenda.can_manage_list_of_speakers'))
elif self.action in ('numbering', ):
result = (has_perm(self.request.user, 'agenda.can_see') and
has_perm(self.request.user, 'agenda.can_manage'))
elif self.action in ("partial_update", "update", "sort", "assign"):
result = (
has_perm(self.request.user, "agenda.can_see")
and has_perm(self.request.user, "agenda.can_see_internal_items")
and has_perm(self.request.user, "agenda.can_manage")
)
elif self.action in ("speak", "sort_speakers"):
result = has_perm(self.request.user, "agenda.can_see") and has_perm(
self.request.user, "agenda.can_manage_list_of_speakers"
)
elif self.action in ("numbering",):
result = has_perm(self.request.user, "agenda.can_see") and has_perm(
self.request.user, "agenda.can_manage"
)
else:
result = False
return result
@ -82,7 +88,7 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
return response
@detail_route(methods=['POST', 'PATCH', 'DELETE'])
@detail_route(methods=["POST", "PATCH", "DELETE"])
def manage_speaker(self, request, pk=None):
"""
Special view endpoint to add users to the list of speakers or remove
@ -103,55 +109,61 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
# Retrieve item.
item = self.get_object()
if request.method == 'POST':
if request.method == "POST":
# Retrieve user_id
user_id = request.data.get('user')
user_id = request.data.get("user")
# Check permissions and other conditions. Get user instance.
if user_id is None:
# Add oneself
if not has_perm(self.request.user, 'agenda.can_be_speaker'):
if not has_perm(self.request.user, "agenda.can_be_speaker"):
self.permission_denied(request)
if item.speaker_list_closed:
raise ValidationError({'detail': _('The list of speakers is closed.')})
raise ValidationError(
{"detail": _("The list of speakers is closed.")}
)
user = self.request.user
else:
# Add someone else.
if not has_perm(self.request.user, 'agenda.can_manage_list_of_speakers'):
if not has_perm(
self.request.user, "agenda.can_manage_list_of_speakers"
):
self.permission_denied(request)
try:
user = get_user_model().objects.get(pk=int(user_id))
except (ValueError, get_user_model().DoesNotExist):
raise ValidationError({'detail': _('User does not exist.')})
raise ValidationError({"detail": _("User does not exist.")})
# Try to add the user. This ensurse that a user is not twice in the
# list of coming speakers.
try:
Speaker.objects.add(user, item)
except OpenSlidesError as e:
raise ValidationError({'detail': str(e)})
message = _('User %s was successfully added to the list of speakers.') % user
raise ValidationError({"detail": str(e)})
message = (
_("User %s was successfully added to the list of speakers.") % user
)
# Send new speaker via autoupdate because users without permission
# to see users may not have it but can get it now.
inform_changed_data([user])
# Toggle 'marked' for the speaker
elif request.method == 'PATCH':
elif request.method == "PATCH":
# Check permissions
if not has_perm(self.request.user, 'agenda.can_manage_list_of_speakers'):
if not has_perm(self.request.user, "agenda.can_manage_list_of_speakers"):
self.permission_denied(request)
# Retrieve user_id
user_id = request.data.get('user')
user_id = request.data.get("user")
try:
user = get_user_model().objects.get(pk=int(user_id))
except (ValueError, get_user_model().DoesNotExist):
raise ValidationError({'detail': _('User does not exist.')})
raise ValidationError({"detail": _("User does not exist.")})
marked = request.data.get('marked')
marked = request.data.get("marked")
if not isinstance(marked, bool):
raise ValidationError({'detail': _('Marked has to be a bool.')})
raise ValidationError({"detail": _("Marked has to be a bool.")})
queryset = Speaker.objects.filter(item=item, user=user)
try:
@ -160,37 +172,46 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
# there is only one speaker instance or none.
speaker = queryset.get()
except Speaker.DoesNotExist:
raise ValidationError({'detail': _('The user is not in the list of speakers.')})
raise ValidationError(
{"detail": _("The user is not in the list of speakers.")}
)
else:
speaker.marked = marked
speaker.save()
if speaker.marked:
message = _('You are successfully marked the speaker.')
message = _("You are successfully marked the speaker.")
else:
message = _('You are successfully unmarked the speaker.')
message = _("You are successfully unmarked the speaker.")
else:
# request.method == 'DELETE'
speaker_ids = request.data.get('speaker')
speaker_ids = request.data.get("speaker")
# Check permissions and other conditions. Get speaker instance.
if speaker_ids is None:
# Remove oneself
queryset = Speaker.objects.filter(
item=item, user=self.request.user).exclude(weight=None)
item=item, user=self.request.user
).exclude(weight=None)
try:
# We assume that there aren't multiple entries because this
# is forbidden by the Manager's add method. We assume that
# there is only one speaker instance or none.
speaker = queryset.get()
except Speaker.DoesNotExist:
raise ValidationError({'detail': _('You are not on the list of speakers.')})
raise ValidationError(
{"detail": _("You are not on the list of speakers.")}
)
else:
speaker.delete()
message = _('You are successfully removed from the list of speakers.')
message = _(
"You are successfully removed from the list of speakers."
)
else:
# Remove someone else.
if not has_perm(self.request.user, 'agenda.can_manage_list_of_speakers'):
if not has_perm(
self.request.user, "agenda.can_manage_list_of_speakers"
):
self.permission_denied(request)
if type(speaker_ids) is int:
speaker_ids = [speaker_ids]
@ -209,15 +230,24 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
inform_changed_data(item)
if deleted_speaker_count > 1:
message = str(deleted_speaker_count) + ' ' + _('speakers have been removed from the list of speakers.')
message = (
str(deleted_speaker_count)
+ " "
+ _("speakers have been removed from the list of speakers.")
)
elif deleted_speaker_count == 1:
message = _('User %s has been removed from the list of speakers.') % deleted_speaker_name
message = (
_("User %s has been removed from the list of speakers.")
% deleted_speaker_name
)
else:
message = _('No speakers have been removed from the list of speakers.')
message = _(
"No speakers have been removed from the list of speakers."
)
# Initiate response.
return Response({'detail': message})
return Response({"detail": message})
@detail_route(methods=['PUT', 'DELETE'])
@detail_route(methods=["PUT", "DELETE"])
def speak(self, request, pk=None):
"""
Special view endpoint to begin and end speech of speakers. Send PUT
@ -227,20 +257,22 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
# Retrieve item.
item = self.get_object()
if request.method == 'PUT':
if request.method == "PUT":
# Retrieve speaker_id
speaker_id = request.data.get('speaker')
speaker_id = request.data.get("speaker")
if speaker_id is None:
speaker = item.get_next_speaker()
if speaker is None:
raise ValidationError({'detail': _('The list of speakers is empty.')})
raise ValidationError(
{"detail": _("The list of speakers is empty.")}
)
else:
try:
speaker = Speaker.objects.get(pk=int(speaker_id))
except (ValueError, Speaker.DoesNotExist):
raise ValidationError({'detail': _('Speaker does not exist.')})
raise ValidationError({"detail": _("Speaker does not exist.")})
speaker.begin_speech()
message = _('User is now speaking.')
message = _("User is now speaking.")
else:
# request.method == 'DELETE'
@ -248,17 +280,27 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
# We assume that there aren't multiple entries because this
# is forbidden by the Model's begin_speech method. We assume that
# there is only one speaker instance or none.
current_speaker = Speaker.objects.filter(item=item, end_time=None).exclude(begin_time=None).get()
current_speaker = (
Speaker.objects.filter(item=item, end_time=None)
.exclude(begin_time=None)
.get()
)
except Speaker.DoesNotExist:
raise ValidationError(
{'detail': _('There is no one speaking at the moment according to %(item)s.') % {'item': item}})
{
"detail": _(
"There is no one speaking at the moment according to %(item)s."
)
% {"item": item}
}
)
current_speaker.end_speech()
message = _('The speech is finished now.')
message = _("The speech is finished now.")
# Initiate response.
return Response({'detail': message})
return Response({"detail": message})
@detail_route(methods=['POST'])
@detail_route(methods=["POST"])
def sort_speakers(self, request, pk=None):
"""
Special view endpoint to sort the list of speakers.
@ -269,10 +311,9 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
item = self.get_object()
# Check data
speaker_ids = request.data.get('speakers')
speaker_ids = request.data.get("speakers")
if not isinstance(speaker_ids, list):
raise ValidationError(
{'detail': _('Invalid data.')})
raise ValidationError({"detail": _("Invalid data.")})
# Get all speakers
speakers = {}
@ -283,8 +324,7 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
valid_speakers = []
for speaker_id in speaker_ids:
if not isinstance(speaker_id, int) or speakers.get(speaker_id) is None:
raise ValidationError(
{'detail': _('Invalid data.')})
raise ValidationError({"detail": _("Invalid data.")})
valid_speakers.append(speakers[speaker_id])
weight = 0
with transaction.atomic():
@ -297,50 +337,57 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
inform_changed_data(item)
# Initiate response.
return Response({'detail': _('List of speakers successfully sorted.')})
return Response({"detail": _("List of speakers successfully sorted.")})
@list_route(methods=['post'])
@list_route(methods=["post"])
def numbering(self, request):
"""
Auto numbering of the agenda according to the config. Manually added
item numbers will be overwritten.
"""
if not config['agenda_enable_numbering']:
raise ValidationError({'detail': _('Numbering of agenda items is deactivated.')})
if not config["agenda_enable_numbering"]:
raise ValidationError(
{"detail": _("Numbering of agenda items is deactivated.")}
)
Item.objects.number_all(numeral_system=config['agenda_numeral_system'])
return Response({'detail': _('The agenda has been numbered.')})
Item.objects.number_all(numeral_system=config["agenda_numeral_system"])
return Response({"detail": _("The agenda has been numbered.")})
@list_route(methods=['post'])
@list_route(methods=["post"])
def sort(self, request):
"""
Sort agenda items. Also checks parent field to prevent hierarchical
loops.
"""
nodes = request.data.get('nodes', [])
parent_id = request.data.get('parent_id')
nodes = request.data.get("nodes", [])
parent_id = request.data.get("parent_id")
items = []
with transaction.atomic():
for index, node in enumerate(nodes):
item = Item.objects.get(pk=node['id'])
item = Item.objects.get(pk=node["id"])
item.parent_id = parent_id
item.weight = index
item.save(skip_autoupdate=True)
items.append(item)
# Now check consistency. TODO: Try to use less DB queries.
item = Item.objects.get(pk=node['id'])
item = Item.objects.get(pk=node["id"])
ancestor = item.parent
while ancestor is not None:
if ancestor == item:
raise ValidationError({'detail': _(
'There must not be a hierarchical loop. Please reload the page.')})
raise ValidationError(
{
"detail": _(
"There must not be a hierarchical loop. Please reload the page."
)
}
)
ancestor = ancestor.parent
inform_changed_data(items)
return Response({'detail': _('The agenda has been sorted.')})
return Response({"detail": _("The agenda has been sorted.")})
@list_route(methods=['post'])
@list_route(methods=["post"])
@transaction.atomic
def assign(self, request):
"""
@ -359,9 +406,7 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
"items": {
"description": "An array of agenda item ids where the items should be assigned to the new parent id.",
"type": "array",
"items": {
"type": "integer",
},
"items": {"type": "integer"},
"minItems": 1,
"uniqueItems": True,
},
@ -377,13 +422,19 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
try:
jsonschema.validate(request.data, schema)
except jsonschema.ValidationError as err:
raise ValidationError({'detail': str(err)})
raise ValidationError({"detail": str(err)})
# Check parent item
try:
parent = Item.objects.get(pk=request.data['parent_id'])
parent = Item.objects.get(pk=request.data["parent_id"])
except Item.DoesNotExist:
raise ValidationError({'detail': 'Parent item {} does not exist'.format(request.data['parent_id'])})
raise ValidationError(
{
"detail": "Parent item {} does not exist".format(
request.data["parent_id"]
)
}
)
# Collect ancestors
ancestors = [parent.pk]
@ -394,16 +445,24 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
# First validate all items before changeing them.
items = []
for item_id in request.data['items']:
for item_id in request.data["items"]:
# Prevent hierarchical loops.
if item_id in ancestors:
raise ValidationError({'detail': 'Assigning item {} to one of its children is not possible.'.format(item_id)})
raise ValidationError(
{
"detail": "Assigning item {} to one of its children is not possible.".format(
item_id
)
}
)
# Check every item
try:
items.append(Item.objects.get(pk=item_id))
except Item.DoesNotExist:
raise ValidationError({'detail': 'Item {} does not exist'.format(item_id)})
raise ValidationError(
{"detail": "Item {} does not exist".format(item_id)}
)
# OK, assign new parents.
for item in items:
@ -415,6 +474,10 @@ class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericV
inform_changed_data(items)
# Send response.
return Response({
'detail': _('{number} items successfully assigned.').format(number=len(items)),
})
return Response(
{
"detail": _("{number} items successfully assigned.").format(
number=len(items)
)
}
)

View File

@ -1 +1 @@
default_app_config = 'openslides.assignments.apps.AssignmentsAppConfig'
default_app_config = "openslides.assignments.apps.AssignmentsAppConfig"

View File

@ -8,31 +8,35 @@ class AssignmentAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Assignment and AssignmentViewSet.
"""
base_permission = 'assignments.can_see'
base_permission = "assignments.can_see"
async def get_restricted_data(
self,
full_data: List[Dict[str, Any]],
user_id: int) -> List[Dict[str, Any]]:
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Returns the restricted serialized data for the instance prepared
for the user. Removes unpublished polls for non admins so that they
only get a result like the AssignmentShortSerializer would give them.
"""
# Parse data.
if await async_has_perm(user_id, 'assignments.can_see') and await async_has_perm(user_id, 'assignments.can_manage'):
if await async_has_perm(
user_id, "assignments.can_see"
) and await async_has_perm(user_id, "assignments.can_manage"):
data = full_data
elif await async_has_perm(user_id, 'assignments.can_see'):
elif await async_has_perm(user_id, "assignments.can_see"):
# Exclude unpublished poll votes.
data = []
for full in full_data:
full_copy = full.copy()
polls = full_copy['polls']
polls = full_copy["polls"]
for poll in polls:
if not poll['published']:
for option in poll['options']:
option['votes'] = [] # clear votes for not published polls
poll['has_votes'] = False # A user should see, if there are votes.
if not poll["published"]:
for option in poll["options"]:
option["votes"] = [] # clear votes for not published polls
poll[
"has_votes"
] = False # A user should see, if there are votes.
data.append(full_copy)
else:
data = []

View File

@ -7,8 +7,8 @@ from ..utils.projector import register_projector_elements
class AssignmentsAppConfig(AppConfig):
name = 'openslides.assignments'
verbose_name = 'OpenSlides Assignments'
name = "openslides.assignments"
verbose_name = "OpenSlides Assignments"
angular_site_module = True
angular_projector_module = True
@ -28,17 +28,23 @@ class AssignmentsAppConfig(AppConfig):
# Connect signals.
permission_change.connect(
get_permission_change_data,
dispatch_uid='assignments_get_permission_change_data')
dispatch_uid="assignments_get_permission_change_data",
)
# Register viewsets.
router.register(self.get_model('Assignment').get_collection_string(), AssignmentViewSet)
router.register('assignments/poll', AssignmentPollViewSet)
router.register(
self.get_model("Assignment").get_collection_string(), AssignmentViewSet
)
router.register("assignments/poll", AssignmentPollViewSet)
# Register required_users
required_user.add_collection_string(self.get_model('Assignment').get_collection_string(), required_users)
required_user.add_collection_string(
self.get_model("Assignment").get_collection_string(), required_users
)
def get_config_variables(self):
from .config_variables import get_config_variables
return get_config_variables()
def get_startup_elements(self):
@ -46,18 +52,15 @@ class AssignmentsAppConfig(AppConfig):
Yields all Cachables required on startup i. e. opening the websocket
connection.
"""
yield self.get_model('Assignment')
yield self.get_model("Assignment")
def get_angular_constants(self):
assignment = self.get_model('Assignment')
Item = TypedDict('Item', {'value': int, 'display_name': str})
assignment = self.get_model("Assignment")
Item = TypedDict("Item", {"value": int, "display_name": str})
phases: List[Item] = []
for phase in assignment.PHASES:
phases.append({
'value': phase[0],
'display_name': phase[1],
})
return {'AssignmentPhases': phases}
phases.append({"value": phase[0], "display_name": phase[1]})
return {"AssignmentPhases": phases}
def required_users(element: Dict[str, Any]) -> Set[int]:
@ -65,7 +68,9 @@ def required_users(element: Dict[str, Any]) -> Set[int]:
Returns all user ids that are displayed as candidates (including poll
options) in the assignment element.
"""
candidates = set(related_user['user_id'] for related_user in element['assignment_related_users'])
for poll in element['polls']:
candidates.update(option['candidate_id'] for option in poll['options'])
candidates = set(
related_user["user_id"] for related_user in element["assignment_related_users"]
)
for poll in element["polls"]:
candidates.update(option["candidate_id"] for option in poll["options"])
return candidates

View File

@ -13,96 +13,118 @@ def get_config_variables():
"""
# Ballot and ballot papers
yield ConfigVariable(
name='assignments_poll_vote_values',
default_value='auto',
input_type='choice',
label='Election method',
name="assignments_poll_vote_values",
default_value="auto",
input_type="choice",
label="Election method",
choices=(
{'value': 'auto', 'display_name': 'Automatic assign of method'},
{'value': 'votes', 'display_name': 'Always one option per candidate'},
{'value': 'yesnoabstain', 'display_name': 'Always Yes-No-Abstain per candidate'},
{'value': 'yesno', 'display_name': 'Always Yes/No per candidate'}),
{"value": "auto", "display_name": "Automatic assign of method"},
{"value": "votes", "display_name": "Always one option per candidate"},
{
"value": "yesnoabstain",
"display_name": "Always Yes-No-Abstain per candidate",
},
{"value": "yesno", "display_name": "Always Yes/No per candidate"},
),
weight=410,
group='Elections',
subgroup='Ballot and ballot papers')
group="Elections",
subgroup="Ballot and ballot papers",
)
yield ConfigVariable(
name='assignments_poll_100_percent_base',
default_value='YES_NO_ABSTAIN',
input_type='choice',
label='The 100-%-base of an election result consists of',
name="assignments_poll_100_percent_base",
default_value="YES_NO_ABSTAIN",
input_type="choice",
label="The 100-%-base of an election result consists of",
choices=(
{'value': 'YES_NO_ABSTAIN', 'display_name': 'Yes/No/Abstain per candidate'},
{'value': 'YES_NO', 'display_name': 'Yes/No per candidate'},
{'value': 'VALID', 'display_name': 'All valid ballots'},
{'value': 'CAST', 'display_name': 'All casted ballots'},
{'value': 'DISABLED', 'display_name': 'Disabled (no percents)'}),
help_text=('For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base '
'depends on the election method: If there is only one option per candidate, '
'the sum of all votes of all candidates is 100 %. Otherwise for each '
'candidate the sum of all votes is 100 %.'),
{"value": "YES_NO_ABSTAIN", "display_name": "Yes/No/Abstain per candidate"},
{"value": "YES_NO", "display_name": "Yes/No per candidate"},
{"value": "VALID", "display_name": "All valid ballots"},
{"value": "CAST", "display_name": "All casted ballots"},
{"value": "DISABLED", "display_name": "Disabled (no percents)"},
),
help_text=(
"For Yes/No/Abstain per candidate and Yes/No per candidate the 100-%-base "
"depends on the election method: If there is only one option per candidate, "
"the sum of all votes of all candidates is 100 %. Otherwise for each "
"candidate the sum of all votes is 100 %."
),
weight=420,
group='Elections',
subgroup='Ballot and ballot papers')
group="Elections",
subgroup="Ballot and ballot papers",
)
# TODO: Add server side validation of the choices.
yield ConfigVariable(
name='assignments_poll_default_majority_method',
default_value=majorityMethods[0]['value'],
input_type='choice',
name="assignments_poll_default_majority_method",
default_value=majorityMethods[0]["value"],
input_type="choice",
choices=majorityMethods,
label='Required majority',
help_text='Default method to check whether a candidate has reached the required majority.',
label="Required majority",
help_text="Default method to check whether a candidate has reached the required majority.",
weight=425,
group='Elections',
subgroup='Ballot and ballot papers')
group="Elections",
subgroup="Ballot and ballot papers",
)
yield ConfigVariable(
name='assignments_add_candidates_to_list_of_speakers',
name="assignments_add_candidates_to_list_of_speakers",
default_value=True,
input_type='boolean',
label='Put all candidates on the list of speakers',
input_type="boolean",
label="Put all candidates on the list of speakers",
weight=428,
group='Elections',
subgroup='Ballot and ballot papers')
group="Elections",
subgroup="Ballot and ballot papers",
)
yield ConfigVariable(
name='assignments_pdf_ballot_papers_selection',
default_value='CUSTOM_NUMBER',
input_type='choice',
label='Number of ballot papers (selection)',
name="assignments_pdf_ballot_papers_selection",
default_value="CUSTOM_NUMBER",
input_type="choice",
label="Number of ballot papers (selection)",
choices=(
{'value': 'NUMBER_OF_DELEGATES', 'display_name': 'Number of all delegates'},
{'value': 'NUMBER_OF_ALL_PARTICIPANTS', 'display_name': 'Number of all participants'},
{'value': 'CUSTOM_NUMBER', 'display_name': 'Use the following custom number'}),
{"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"},
{
"value": "NUMBER_OF_ALL_PARTICIPANTS",
"display_name": "Number of all participants",
},
{
"value": "CUSTOM_NUMBER",
"display_name": "Use the following custom number",
},
),
weight=430,
group='Elections',
subgroup='Ballot and ballot papers')
group="Elections",
subgroup="Ballot and ballot papers",
)
yield ConfigVariable(
name='assignments_pdf_ballot_papers_number',
name="assignments_pdf_ballot_papers_number",
default_value=8,
input_type='integer',
label='Custom number of ballot papers',
input_type="integer",
label="Custom number of ballot papers",
weight=440,
group='Elections',
subgroup='Ballot and ballot papers',
validators=(MinValueValidator(1),))
group="Elections",
subgroup="Ballot and ballot papers",
validators=(MinValueValidator(1),),
)
# PDF
yield ConfigVariable(
name='assignments_pdf_title',
default_value='Elections',
label='Title for PDF document (all elections)',
name="assignments_pdf_title",
default_value="Elections",
label="Title for PDF document (all elections)",
weight=460,
group='Elections',
subgroup='PDF')
group="Elections",
subgroup="PDF",
)
yield ConfigVariable(
name='assignments_pdf_preamble',
default_value='',
label='Preamble text for PDF document (all elections)',
name="assignments_pdf_preamble",
default_value="",
label="Preamble text for PDF document (all elections)",
weight=470,
group='Elections',
subgroup='PDF')
group="Elections",
subgroup="PDF",
)

View File

@ -15,104 +15,196 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0001_initial'),
("core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='Assignment',
name="Assignment",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('open_posts', models.PositiveSmallIntegerField()),
('poll_description_default', models.CharField(blank=True, max_length=79)),
('phase', models.IntegerField(choices=[(0, 'Searching for candidates'), (1, 'Voting'), (2, 'Finished')], default=0)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=100)),
("description", models.TextField(blank=True)),
("open_posts", models.PositiveSmallIntegerField()),
(
"poll_description_default",
models.CharField(blank=True, max_length=79),
),
(
"phase",
models.IntegerField(
choices=[
(0, "Searching for candidates"),
(1, "Voting"),
(2, "Finished"),
],
default=0,
),
),
],
options={
'verbose_name': 'Election',
'default_permissions': (),
'permissions': (
('can_see', 'Can see elections'),
('can_nominate_other', 'Can nominate another participant'),
('can_nominate_self', 'Can nominate oneself'),
('can_manage', 'Can manage elections')),
'ordering': ('title',),
"verbose_name": "Election",
"default_permissions": (),
"permissions": (
("can_see", "Can see elections"),
("can_nominate_other", "Can nominate another participant"),
("can_nominate_self", "Can nominate oneself"),
("can_manage", "Can manage elections"),
),
"ordering": ("title",),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='AssignmentOption',
name="AssignmentOption",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"candidate",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='AssignmentPoll',
name="AssignmentPoll",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('votesvalid', openslides.utils.models.MinMaxIntegerField(blank=True, null=True)),
('votesinvalid', openslides.utils.models.MinMaxIntegerField(blank=True, null=True)),
('votescast', openslides.utils.models.MinMaxIntegerField(blank=True, null=True)),
('published', models.BooleanField(default=False)),
('yesnoabstain', models.BooleanField(default=False)),
('description', models.CharField(blank=True, max_length=79)),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='polls', to='assignments.Assignment')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"votesvalid",
openslides.utils.models.MinMaxIntegerField(blank=True, null=True),
),
(
"votesinvalid",
openslides.utils.models.MinMaxIntegerField(blank=True, null=True),
),
(
"votescast",
openslides.utils.models.MinMaxIntegerField(blank=True, null=True),
),
("published", models.BooleanField(default=False)),
("yesnoabstain", models.BooleanField(default=False)),
("description", models.CharField(blank=True, max_length=79)),
(
"assignment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="polls",
to="assignments.Assignment",
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='AssignmentRelatedUser',
name="AssignmentRelatedUser",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('elected', models.BooleanField(default=False)),
('assignment', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='assignment_related_users', to='assignments.Assignment')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("elected", models.BooleanField(default=False)),
(
"assignment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="assignment_related_users",
to="assignments.Assignment",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='AssignmentVote',
name="AssignmentVote",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('weight', models.IntegerField(default=1, null=True)),
('value', models.CharField(max_length=255, null=True)),
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='assignments.AssignmentOption')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("weight", models.IntegerField(default=1, null=True)),
("value", models.CharField(max_length=255, null=True)),
(
"option",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="votes",
to="assignments.AssignmentOption",
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AddField(
model_name='assignmentoption',
name='poll',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='assignments.AssignmentPoll'),
model_name="assignmentoption",
name="poll",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="options",
to="assignments.AssignmentPoll",
),
),
migrations.AddField(
model_name='assignment',
name='related_users',
field=models.ManyToManyField(through='assignments.AssignmentRelatedUser', to=settings.AUTH_USER_MODEL),
model_name="assignment",
name="related_users",
field=models.ManyToManyField(
through="assignments.AssignmentRelatedUser", to=settings.AUTH_USER_MODEL
),
),
migrations.AddField(
model_name='assignment',
name='tags',
field=models.ManyToManyField(blank=True, to='core.Tag'),
model_name="assignment",
name="tags",
field=models.ManyToManyField(blank=True, to="core.Tag"),
),
migrations.AlterUniqueTogether(
name='assignmentrelateduser',
unique_together=set([('assignment', 'user')]),
name="assignmentrelateduser", unique_together=set([("assignment", "user")])
),
]

View File

@ -7,18 +7,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assignments', '0001_initial'),
]
dependencies = [("assignments", "0001_initial")]
operations = [
migrations.RemoveField(
model_name='assignmentpoll',
name='yesnoabstain',
),
migrations.RemoveField(model_name="assignmentpoll", name="yesnoabstain"),
migrations.AddField(
model_name='assignmentpoll',
name='pollmethod',
field=models.CharField(default='yna', max_length=5),
model_name="assignmentpoll",
name="pollmethod",
field=models.CharField(default="yna", max_length=5),
),
]

View File

@ -7,19 +7,17 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assignments', '0002_assignmentpoll_pollmethod'),
]
dependencies = [("assignments", "0002_assignmentpoll_pollmethod")]
operations = [
migrations.AddField(
model_name='assignmentrelateduser',
name='weight',
model_name="assignmentrelateduser",
name="weight",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='assignmentoption',
name='weight',
model_name="assignmentoption",
name="weight",
field=models.IntegerField(default=0),
),
]

View File

@ -9,19 +9,17 @@ from openslides.utils.models import MinMaxIntegerField
class Migration(migrations.Migration):
dependencies = [
('assignments', '0003_candidate_weight'),
]
dependencies = [("assignments", "0003_candidate_weight")]
operations = [
migrations.AddField(
model_name='assignmentpoll',
name='votesabstain',
model_name="assignmentpoll",
name="votesabstain",
field=MinMaxIntegerField(null=True, blank=True, min_value=-2),
),
migrations.AddField(
model_name='assignmentpoll',
name='votesno',
model_name="assignmentpoll",
name="votesno",
field=MinMaxIntegerField(null=True, blank=True, min_value=-2),
),
]

View File

@ -8,69 +8,73 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assignments', '0004_auto_20180703_1523'),
]
dependencies = [("assignments", "0004_auto_20180703_1523")]
operations = [
migrations.AlterField(
model_name='assignmentpoll',
name='votescast',
model_name="assignmentpoll",
name="votescast",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='assignmentpoll',
name='votesinvalid',
model_name="assignmentpoll",
name="votesinvalid",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='assignmentpoll',
name='votesvalid',
model_name="assignmentpoll",
name="votesvalid",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='assignmentvote',
name='weight',
model_name="assignmentvote",
name="weight",
field=models.DecimalField(
decimal_places=6,
default=Decimal('1'),
default=Decimal("1"),
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='assignmentpoll',
name='votesabstain',
model_name="assignmentpoll",
name="votesabstain",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='assignmentpoll',
name='votesno',
model_name="assignmentpoll",
name="votesno",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
]

View File

@ -31,16 +31,13 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model):
"""
assignment = models.ForeignKey(
'Assignment',
on_delete=models.CASCADE,
related_name='assignment_related_users')
"Assignment", on_delete=models.CASCADE, related_name="assignment_related_users"
)
"""
ForeinKey to the assignment.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
"""
ForeinKey to the user who is related to the assignment.
"""
@ -57,7 +54,7 @@ class AssignmentRelatedUser(RESTModelMixin, models.Model):
class Meta:
default_permissions = ()
unique_together = ('assignment', 'user')
unique_together = ("assignment", "user")
def __str__(self):
return "%s <-> %s" % (self.assignment, self.user)
@ -73,6 +70,7 @@ class AssignmentManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all assignments. In the background
@ -80,18 +78,17 @@ class AssignmentManager(models.Manager):
polls are prefetched from the database.
"""
return self.get_queryset().prefetch_related(
'related_users',
'agenda_items',
'polls',
'tags')
"related_users", "agenda_items", "polls", "tags"
)
class Assignment(RESTModelMixin, models.Model):
"""
Model for assignments.
"""
access_permissions = AssignmentAccessPermissions()
can_see_permission = 'assignments.can_see'
can_see_permission = "assignments.can_see"
objects = AssignmentManager()
@ -100,19 +97,17 @@ class Assignment(RESTModelMixin, models.Model):
PHASE_FINISHED = 2
PHASES = (
(PHASE_SEARCH, 'Searching for candidates'),
(PHASE_VOTING, 'Voting'),
(PHASE_FINISHED, 'Finished'),
(PHASE_SEARCH, "Searching for candidates"),
(PHASE_VOTING, "Voting"),
(PHASE_FINISHED, "Finished"),
)
title = models.CharField(
max_length=100)
title = models.CharField(max_length=100)
"""
Title of the assignment.
"""
description = models.TextField(
blank=True)
description = models.TextField(blank=True)
"""
Text to describe the assignment.
"""
@ -122,23 +117,19 @@ class Assignment(RESTModelMixin, models.Model):
The number of members to be elected.
"""
poll_description_default = models.CharField(
max_length=79,
blank=True)
poll_description_default = models.CharField(max_length=79, blank=True)
"""
Default text for the poll description.
"""
phase = models.IntegerField(
choices=PHASES,
default=PHASE_SEARCH)
phase = models.IntegerField(choices=PHASES, default=PHASE_SEARCH)
"""
Phase in which the assignment is.
"""
related_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
through='AssignmentRelatedUser')
settings.AUTH_USER_MODEL, through="AssignmentRelatedUser"
)
"""
Users that are candidates or elected.
@ -152,18 +143,18 @@ class Assignment(RESTModelMixin, models.Model):
# 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='assignments')
agenda_items = GenericRelation(Item, related_name="assignments")
class Meta:
default_permissions = ()
permissions = (
('can_see', 'Can see elections'),
('can_nominate_other', 'Can nominate another participant'),
('can_nominate_self', 'Can nominate oneself'),
('can_manage', 'Can manage elections'),
("can_see", "Can see elections"),
("can_nominate_other", "Can nominate another participant"),
("can_nominate_self", "Can nominate oneself"),
("can_manage", "Can manage elections"),
)
ordering = ('title', )
verbose_name = ugettext_noop('Election')
ordering = ("title",)
verbose_name = ugettext_noop("Election")
def __str__(self):
return self.title
@ -174,26 +165,25 @@ class Assignment(RESTModelMixin, models.Model):
assignment projector element is disabled.
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='assignments/assignment',
id=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore # TODO fix typing
skip_autoupdate=skip_autoupdate, name="assignments/assignment", id=self.pk
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
@property
def candidates(self):
"""
Queryset that represents the candidates for the assignment.
"""
return self.related_users.filter(
assignmentrelateduser__elected=False)
return self.related_users.filter(assignmentrelateduser__elected=False)
@property
def elected(self):
"""
Queryset that represents all elected users for the assignment.
"""
return self.related_users.filter(
assignmentrelateduser__elected=True)
return self.related_users.filter(assignmentrelateduser__elected=True)
def is_candidate(self, user):
"""
@ -215,22 +205,22 @@ class Assignment(RESTModelMixin, models.Model):
"""
Adds the user as candidate.
"""
weight = self.assignment_related_users.aggregate(
models.Max('weight'))['weight__max'] or 0
defaults = {
'elected': False,
'weight': weight + 1}
weight = (
self.assignment_related_users.aggregate(models.Max("weight"))["weight__max"]
or 0
)
defaults = {"elected": False, "weight": weight + 1}
related_user, __ = self.assignment_related_users.update_or_create(
user=user,
defaults=defaults)
user=user, defaults=defaults
)
def set_elected(self, user):
"""
Makes user an elected user for this assignment.
"""
related_user, __ = self.assignment_related_users.update_or_create(
user=user,
defaults={'elected': True})
user=user, defaults={"elected": True}
)
def delete_related_user(self, user):
"""
@ -258,39 +248,43 @@ class Assignment(RESTModelMixin, models.Model):
candidates = self.candidates.all()
# Find out the method of the election
if config['assignments_poll_vote_values'] == 'votes':
pollmethod = 'votes'
elif config['assignments_poll_vote_values'] == 'yesnoabstain':
pollmethod = 'yna'
elif config['assignments_poll_vote_values'] == 'yesno':
pollmethod = 'yn'
if config["assignments_poll_vote_values"] == "votes":
pollmethod = "votes"
elif config["assignments_poll_vote_values"] == "yesnoabstain":
pollmethod = "yna"
elif config["assignments_poll_vote_values"] == "yesno":
pollmethod = "yn"
else:
# config['assignments_poll_vote_values'] == 'auto'
# candidates <= available posts -> yes/no/abstain
if len(candidates) <= (self.open_posts - self.elected.count()):
pollmethod = 'yna'
pollmethod = "yna"
else:
pollmethod = 'votes'
pollmethod = "votes"
# Create the poll with the candidates.
poll = self.polls.create(
description=self.poll_description_default,
pollmethod=pollmethod)
description=self.poll_description_default, pollmethod=pollmethod
)
options = []
related_users = AssignmentRelatedUser.objects.filter(assignment__id=self.id).exclude(elected=True)
related_users = AssignmentRelatedUser.objects.filter(
assignment__id=self.id
).exclude(elected=True)
for related_user in related_users:
options.append({
'candidate': related_user.user,
'weight': related_user.weight})
options.append(
{"candidate": related_user.user, "weight": related_user.weight}
)
poll.set_options(options, skip_autoupdate=True)
inform_changed_data(self)
# Add all candidates to list of speakers of related agenda item
# TODO: Try to do this in a bulk create
if config['assignments_add_candidates_to_list_of_speakers']:
if config["assignments_add_candidates_to_list_of_speakers"]:
for candidate in self.candidates:
try:
Speaker.objects.add(candidate, self.agenda_item, skip_autoupdate=True)
Speaker.objects.add(
candidate, self.agenda_item, skip_autoupdate=True
)
except OpenSlidesError:
# The Speaker is already on the list. Do nothing.
# TODO: Find a smart way not to catch the error concerning AnonymousUser.
@ -349,7 +343,7 @@ class Assignment(RESTModelMixin, models.Model):
Return a title for the agenda with the appended assignment verbose name.
Note: It has to be the same return value like in JavaScript.
"""
return '%s (%s)' % (self.get_agenda_title(), _(self._meta.verbose_name))
return "%s (%s)" % (self.get_agenda_title(), _(self._meta.verbose_name))
@property
def agenda_item(self):
@ -370,9 +364,8 @@ class Assignment(RESTModelMixin, models.Model):
class AssignmentVote(RESTModelMixin, BaseVote):
option = models.ForeignKey(
'AssignmentOption',
on_delete=models.CASCADE,
related_name='votes')
"AssignmentOption", on_delete=models.CASCADE, related_name="votes"
)
class Meta:
default_permissions = ()
@ -386,12 +379,9 @@ class AssignmentVote(RESTModelMixin, BaseVote):
class AssignmentOption(RESTModelMixin, BaseOption):
poll = models.ForeignKey(
'AssignmentPoll',
on_delete=models.CASCADE,
related_name='options')
candidate = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
"AssignmentPoll", on_delete=models.CASCADE, related_name="options"
)
candidate = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
weight = models.IntegerField(default=0)
vote_class = AssignmentVote
@ -411,26 +401,32 @@ class AssignmentOption(RESTModelMixin, BaseOption):
# TODO: remove the type-ignoring in the next line, after this is solved:
# https://github.com/python/mypy/issues/3855
class AssignmentPoll(RESTModelMixin, CollectDefaultVotesMixin, # type: ignore
PublishPollMixin, BasePoll):
class AssignmentPoll( # type: ignore
RESTModelMixin, CollectDefaultVotesMixin, PublishPollMixin, BasePoll
):
option_class = AssignmentOption
assignment = models.ForeignKey(
Assignment,
on_delete=models.CASCADE,
related_name='polls')
pollmethod = models.CharField(
max_length=5,
default='yna')
description = models.CharField(
max_length=79,
blank=True)
Assignment, on_delete=models.CASCADE, related_name="polls"
)
pollmethod = models.CharField(max_length=5, default="yna")
description = models.CharField(max_length=79, blank=True)
votesabstain = models.DecimalField(null=True, blank=True, validators=[
MinValueValidator(Decimal('-2'))], max_digits=15, decimal_places=6)
votesabstain = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
""" General abstain votes, used for pollmethod 'votes' """
votesno = models.DecimalField(null=True, blank=True, validators=[
MinValueValidator(Decimal('-2'))], max_digits=15, decimal_places=6)
votesno = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
""" General no votes, used for pollmethod 'votes' """
class Meta:
@ -443,27 +439,30 @@ class AssignmentPoll(RESTModelMixin, CollectDefaultVotesMixin, # type: ignore
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='assignments/assignment',
name="assignments/assignment",
id=self.assignment.pk,
poll=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore # TODO: fix typing
poll=self.pk,
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
def get_assignment(self):
return self.assignment
def get_vote_values(self):
if self.pollmethod == 'yna':
return ['Yes', 'No', 'Abstain']
elif self.pollmethod == 'yn':
return ['Yes', 'No']
if self.pollmethod == "yna":
return ["Yes", "No", "Abstain"]
elif self.pollmethod == "yn":
return ["Yes", "No"]
else:
return ['Votes']
return ["Votes"]
def get_ballot(self):
return self.assignment.polls.filter(id__lte=self.pk).count()
def get_percent_base_choice(self):
return config['assignments_poll_100_percent_base']
return config["assignments_poll_100_percent_base"]
def get_root_rest_element(self):
"""

View File

@ -11,30 +11,33 @@ class AssignmentSlide(ProjectorElement):
You can send a poll id to get a poll slide.
"""
name = 'assignments/assignment'
name = "assignments/assignment"
def check_data(self):
if not Assignment.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('Election does not exist.')
poll_id = self.config_entry.get('poll')
if not Assignment.objects.filter(pk=self.config_entry.get("id")).exists():
raise ProjectorException("Election does not exist.")
poll_id = self.config_entry.get("poll")
if poll_id:
# Poll slide.
try:
poll = AssignmentPoll.objects.get(pk=poll_id)
except AssignmentPoll.DoesNotExist:
raise ProjectorException('Poll does not exist.')
if poll.assignment_id != self.config_entry.get('id'):
raise ProjectorException('Assignment id and poll do not belong together.')
raise ProjectorException("Poll does not exist.")
if poll.assignment_id != self.config_entry.get("id"):
raise ProjectorException(
"Assignment id and poll do not belong together."
)
def update_data(self):
data = None
try:
assignment = Assignment.objects.get(pk=self.config_entry.get('id'))
assignment = Assignment.objects.get(pk=self.config_entry.get("id"))
except Assignment.DoesNotExist:
# Assignment does not exist, so just do nothing.
pass
else:
data = {'agenda_item_id': assignment.agenda_item_id}
data = {"agenda_item_id": assignment.agenda_item_id}
return data

View File

@ -28,8 +28,10 @@ def posts_validator(data):
"""
Validator for open posts. It checks that the values for the open posts are greater than 0.
"""
if (data['open_posts'] and data['open_posts'] is not None and data['open_posts'] < 1):
raise ValidationError({'detail': _('Value for {} must be greater than 0').format('open_posts')})
if data["open_posts"] and data["open_posts"] is not None and data["open_posts"] < 1:
raise ValidationError(
{"detail": _("Value for {} must be greater than 0").format("open_posts")}
)
return data
@ -37,35 +39,39 @@ class AssignmentRelatedUserSerializer(ModelSerializer):
"""
Serializer for assignment.models.AssignmentRelatedUser objects.
"""
class Meta:
model = AssignmentRelatedUser
fields = (
'id',
'user',
'elected',
'assignment',
'weight') # js-data needs the assignment-id in the nested object to define relations.
"id",
"user",
"elected",
"assignment",
"weight",
) # js-data needs the assignment-id in the nested object to define relations.
class AssignmentVoteSerializer(ModelSerializer):
"""
Serializer for assignment.models.AssignmentVote objects.
"""
class Meta:
model = AssignmentVote
fields = ('weight', 'value',)
fields = ("weight", "value")
class AssignmentOptionSerializer(ModelSerializer):
"""
Serializer for assignment.models.AssignmentOption objects.
"""
votes = AssignmentVoteSerializer(many=True, read_only=True)
is_elected = SerializerMethodField()
class Meta:
model = AssignmentOption
fields = ('id', 'candidate', 'is_elected', 'votes', 'poll', 'weight')
fields = ("id", "candidate", "is_elected", "votes", "poll", "weight")
def get_is_elected(self, obj):
"""
@ -78,6 +84,7 @@ class FilterPollListSerializer(ListSerializer):
"""
Customized serializer to filter polls (exclude unpublished).
"""
def to_representation(self, data):
"""
List of object instances -> List of dicts of primitive datatypes.
@ -86,7 +93,9 @@ class FilterPollListSerializer(ListSerializer):
"""
# Dealing with nested relationships, data can be a Manager,
# so, first get a queryset from the Manager if needed
iterable = data.filter(published=True) if isinstance(data, models.Manager) else data
iterable = (
data.filter(published=True) if isinstance(data, models.Manager) else data
)
return [self.child.to_representation(item) for item in iterable]
@ -96,31 +105,35 @@ class AssignmentAllPollSerializer(ModelSerializer):
Serializes all polls.
"""
options = AssignmentOptionSerializer(many=True, read_only=True)
votes = ListField(
child=DictField(
child=DecimalField(max_digits=15, decimal_places=6, min_value=-2)),
child=DecimalField(max_digits=15, decimal_places=6, min_value=-2)
),
write_only=True,
required=False)
required=False,
)
has_votes = SerializerMethodField()
class Meta:
model = AssignmentPoll
fields = (
'id',
'pollmethod',
'description',
'published',
'options',
'votesabstain',
'votesno',
'votesvalid',
'votesinvalid',
'votescast',
'votes',
'has_votes',
'assignment') # js-data needs the assignment-id in the nested object to define relations.
read_only_fields = ('pollmethod',)
"id",
"pollmethod",
"description",
"published",
"options",
"votesabstain",
"votesno",
"votesvalid",
"votesinvalid",
"votescast",
"votes",
"has_votes",
"assignment",
) # js-data needs the assignment-id in the nested object to define relations.
read_only_fields = ("pollmethod",)
validators = (default_votes_validator,)
def get_has_votes(self, obj):
@ -144,30 +157,45 @@ class AssignmentAllPollSerializer(ModelSerializer):
"votes": [{"Votes": 10}, {"Votes": 0}]
"""
# Update votes.
votes = validated_data.get('votes')
votes = validated_data.get("votes")
if votes:
options = list(instance.get_options())
if len(votes) != len(options):
raise ValidationError({
'detail': _('You have to submit data for %d candidates.') % len(options)})
raise ValidationError(
{
"detail": _("You have to submit data for %d candidates.")
% len(options)
}
)
for index, option in enumerate(options):
if len(votes[index]) != len(instance.get_vote_values()):
raise ValidationError({
'detail': _('You have to submit data for %d vote values.') % len(instance.get_vote_values())})
raise ValidationError(
{
"detail": _("You have to submit data for %d vote values.")
% len(instance.get_vote_values())
}
)
for vote_value, vote_weight in votes[index].items():
if vote_value not in instance.get_vote_values():
raise ValidationError({
'detail': _('Vote value %s is invalid.') % vote_value})
instance.set_vote_objects_with_values(option, votes[index], skip_autoupdate=True)
raise ValidationError(
{"detail": _("Vote value %s is invalid.") % vote_value}
)
instance.set_vote_objects_with_values(
option, votes[index], skip_autoupdate=True
)
# Update remaining writeable fields.
instance.description = validated_data.get('description', instance.description)
instance.published = validated_data.get('published', instance.published)
instance.votesabstain = validated_data.get('votesabstain', instance.votesabstain)
instance.votesno = validated_data.get('votesno', instance.votesno)
instance.votesvalid = validated_data.get('votesvalid', instance.votesvalid)
instance.votesinvalid = validated_data.get('votesinvalid', instance.votesinvalid)
instance.votescast = validated_data.get('votescast', instance.votescast)
instance.description = validated_data.get("description", instance.description)
instance.published = validated_data.get("published", instance.published)
instance.votesabstain = validated_data.get(
"votesabstain", instance.votesabstain
)
instance.votesno = validated_data.get("votesno", instance.votesno)
instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid)
instance.votesinvalid = validated_data.get(
"votesinvalid", instance.votesinvalid
)
instance.votescast = validated_data.get("votescast", instance.votescast)
instance.save()
return instance
@ -178,52 +206,60 @@ class AssignmentShortPollSerializer(AssignmentAllPollSerializer):
Serializes only short polls (excluded unpublished polls).
"""
class Meta:
list_serializer_class = FilterPollListSerializer
model = AssignmentPoll
fields = (
'id',
'pollmethod',
'description',
'published',
'options',
'votesabstain',
'votesno',
'votesvalid',
'votesinvalid',
'votescast',
'has_votes',)
"id",
"pollmethod",
"description",
"published",
"options",
"votesabstain",
"votesno",
"votesvalid",
"votesinvalid",
"votescast",
"has_votes",
)
class AssignmentFullSerializer(ModelSerializer):
"""
Serializer for assignment.models.Assignment objects. With all polls.
"""
assignment_related_users = AssignmentRelatedUserSerializer(many=True, read_only=True)
assignment_related_users = AssignmentRelatedUserSerializer(
many=True, read_only=True
)
polls = AssignmentAllPollSerializer(many=True, read_only=True)
agenda_type = IntegerField(write_only=True, required=False, min_value=1, max_value=3)
agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3
)
agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)
class Meta:
model = Assignment
fields = (
'id',
'title',
'description',
'open_posts',
'phase',
'assignment_related_users',
'poll_description_default',
'polls',
'agenda_item_id',
'agenda_type',
'agenda_parent_id',
'tags',)
"id",
"title",
"description",
"open_posts",
"phase",
"assignment_related_users",
"poll_description_default",
"polls",
"agenda_item_id",
"agenda_type",
"agenda_parent_id",
"tags",
)
validators = (posts_validator,)
def validate(self, data):
if 'description' in data:
data['description'] = validate_html(data['description'])
if "description" in data:
data["description"] = validate_html(data["description"])
return data
def create(self, validated_data):
@ -231,10 +267,10 @@ class AssignmentFullSerializer(ModelSerializer):
Customized create method. Set information about related agenda item
into agenda_item_update_information container.
"""
agenda_type = validated_data.pop('agenda_type', None)
agenda_parent_id = validated_data.pop('agenda_parent_id', None)
agenda_type = validated_data.pop("agenda_type", None)
agenda_parent_id = validated_data.pop("agenda_parent_id", None)
assignment = Assignment(**validated_data)
assignment.agenda_item_update_information['type'] = agenda_type
assignment.agenda_item_update_information['parent_id'] = agenda_parent_id
assignment.agenda_item_update_information["type"] = agenda_type
assignment.agenda_item_update_information["parent_id"] = agenda_parent_id
assignment.save()
return assignment

View File

@ -5,8 +5,11 @@ def get_permission_change_data(sender, permissions=None, **kwargs):
"""
Yields all necessary collections if 'assignments.can_see' permission changes.
"""
assignments_app = apps.get_app_config(app_label='assignments')
assignments_app = apps.get_app_config(app_label="assignments")
for permission in permissions:
# There could be only one 'assignment.can_see' and then we want to return data.
if permission.content_type.app_label == assignments_app.label and permission.codename == 'can_see':
if (
permission.content_type.app_label == assignments_app.label
and permission.codename == "can_see"
):
yield from assignments_app.get_startup_elements()

View File

@ -21,6 +21,7 @@ from .serializers import AssignmentAllPollSerializer
# Viewsets for the REST API
class AssignmentViewSet(ModelViewSet):
"""
API endpoint for assignments.
@ -29,6 +30,7 @@ class AssignmentViewSet(ModelViewSet):
partial_update, update, destroy, candidature_self, candidature_other,
mark_elected and create_poll.
"""
access_permissions = AssignmentAccessPermissions()
queryset = Assignment.objects.all()
@ -36,26 +38,36 @@ class AssignmentViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
elif self.action == "metadata":
# Everybody is allowed to see the metadata.
result = True
elif self.action in ('create', 'partial_update', 'update', 'destroy',
'mark_elected', 'create_poll', 'sort_related_users'):
result = (has_perm(self.request.user, 'assignments.can_see') and
has_perm(self.request.user, 'assignments.can_manage'))
elif self.action == 'candidature_self':
result = (has_perm(self.request.user, 'assignments.can_see') and
has_perm(self.request.user, 'assignments.can_nominate_self'))
elif self.action == 'candidature_other':
result = (has_perm(self.request.user, 'assignments.can_see') and
has_perm(self.request.user, 'assignments.can_nominate_other'))
elif self.action in (
"create",
"partial_update",
"update",
"destroy",
"mark_elected",
"create_poll",
"sort_related_users",
):
result = has_perm(self.request.user, "assignments.can_see") and has_perm(
self.request.user, "assignments.can_manage"
)
elif self.action == "candidature_self":
result = has_perm(self.request.user, "assignments.can_see") and has_perm(
self.request.user, "assignments.can_nominate_self"
)
elif self.action == "candidature_other":
result = has_perm(self.request.user, "assignments.can_see") and has_perm(
self.request.user, "assignments.can_nominate_other"
)
else:
result = False
return result
@detail_route(methods=['post', 'delete'])
@detail_route(methods=["post", "delete"])
def candidature_self(self, request, pk=None):
"""
View to nominate self as candidate (POST) or withdraw own
@ -63,18 +75,26 @@ class AssignmentViewSet(ModelViewSet):
"""
assignment = self.get_object()
if assignment.is_elected(request.user):
raise ValidationError({'detail': _('You are already elected.')})
if request.method == 'POST':
raise ValidationError({"detail": _("You are already elected.")})
if request.method == "POST":
message = self.nominate_self(request, assignment)
else:
# request.method == 'DELETE'
message = self.withdraw_self(request, assignment)
return Response({'detail': message})
return Response({"detail": message})
def nominate_self(self, request, assignment):
if assignment.phase == assignment.PHASE_FINISHED:
raise ValidationError({'detail': _('You can not candidate to this election because it is finished.')})
if assignment.phase == assignment.PHASE_VOTING and not has_perm(request.user, 'assignments.can_manage'):
raise ValidationError(
{
"detail": _(
"You can not candidate to this election because it is finished."
)
}
)
if assignment.phase == assignment.PHASE_VOTING and not has_perm(
request.user, "assignments.can_manage"
):
# To nominate self during voting you have to be a manager.
self.permission_denied(request)
# If the request.user is already a candidate he can nominate himself nevertheless.
@ -82,19 +102,29 @@ class AssignmentViewSet(ModelViewSet):
# Send new candidate via autoupdate because users without permission
# to see users may not have it but can get it now.
inform_changed_data([request.user])
return _('You were nominated successfully.')
return _("You were nominated successfully.")
def withdraw_self(self, request, assignment):
# Withdraw candidature.
if assignment.phase == assignment.PHASE_FINISHED:
raise ValidationError({'detail': _('You can not withdraw your candidature to this election because it is finished.')})
if assignment.phase == assignment.PHASE_VOTING and not has_perm(request.user, 'assignments.can_manage'):
raise ValidationError(
{
"detail": _(
"You can not withdraw your candidature to this election because it is finished."
)
}
)
if assignment.phase == assignment.PHASE_VOTING and not has_perm(
request.user, "assignments.can_manage"
):
# To withdraw self during voting you have to be a manager.
self.permission_denied(request)
if not assignment.is_candidate(request.user):
raise ValidationError({'detail': _('You are not a candidate of this election.')})
raise ValidationError(
{"detail": _("You are not a candidate of this election.")}
)
assignment.delete_related_user(request.user)
return _('You have withdrawn your candidature successfully.')
return _("You have withdrawn your candidature successfully.")
def get_user_from_request_data(self, request):
"""
@ -103,20 +133,26 @@ class AssignmentViewSet(ModelViewSet):
self.mark_elected can play with it.
"""
if not isinstance(request.data, dict):
detail = _('Invalid data. Expected dictionary, got %s.') % type(request.data)
raise ValidationError({'detail': detail})
user_str = request.data.get('user', '')
detail = _("Invalid data. Expected dictionary, got %s.") % type(
request.data
)
raise ValidationError({"detail": detail})
user_str = request.data.get("user", "")
try:
user_pk = int(user_str)
except ValueError:
raise ValidationError({'detail': _('Invalid data. Expected something like {"user": <id>}.')})
raise ValidationError(
{"detail": _('Invalid data. Expected something like {"user": <id>}.')}
)
try:
user = get_user_model().objects.get(pk=user_pk)
except get_user_model().DoesNotExist:
raise ValidationError({'detail': _('Invalid data. User %d does not exist.') % user_pk})
raise ValidationError(
{"detail": _("Invalid data. User %d does not exist.") % user_pk}
)
return user
@detail_route(methods=['post', 'delete'])
@detail_route(methods=["post", "delete"])
def candidature_other(self, request, pk=None):
"""
View to nominate other users (POST) or delete their candidature
@ -124,43 +160,51 @@ class AssignmentViewSet(ModelViewSet):
"""
user = self.get_user_from_request_data(request)
assignment = self.get_object()
if request.method == 'POST':
if request.method == "POST":
message = self.nominate_other(request, user, assignment)
else:
# request.method == 'DELETE'
message = self.delete_other(request, user, assignment)
return Response({'detail': message})
return Response({"detail": message})
def nominate_other(self, request, user, assignment):
if assignment.is_elected(user):
raise ValidationError({'detail': _('User %s is already elected.') % user})
raise ValidationError({"detail": _("User %s is already elected.") % user})
if assignment.phase == assignment.PHASE_FINISHED:
detail = _('You can not nominate someone to this election because it is finished.')
raise ValidationError({'detail': detail})
if assignment.phase == assignment.PHASE_VOTING and not has_perm(request.user, 'assignments.can_manage'):
detail = _(
"You can not nominate someone to this election because it is finished."
)
raise ValidationError({"detail": detail})
if assignment.phase == assignment.PHASE_VOTING and not has_perm(
request.user, "assignments.can_manage"
):
# To nominate another user during voting you have to be a manager.
self.permission_denied(request)
if assignment.is_candidate(user):
raise ValidationError({'detail': _('User %s is already nominated.') % user})
raise ValidationError({"detail": _("User %s is already nominated.") % user})
assignment.set_candidate(user)
# Send new candidate via autoupdate because users without permission
# to see users may not have it but can get it now.
inform_changed_data(user)
return _('User %s was nominated successfully.') % user
return _("User %s was nominated successfully.") % user
def delete_other(self, request, user, assignment):
# To delete candidature status you have to be a manager.
if not has_perm(request.user, 'assignments.can_manage'):
if not has_perm(request.user, "assignments.can_manage"):
self.permission_denied(request)
if assignment.phase == assignment.PHASE_FINISHED:
detail = _("You can not delete someone's candidature to this election because it is finished.")
raise ValidationError({'detail': detail})
detail = _(
"You can not delete someone's candidature to this election because it is finished."
)
raise ValidationError({"detail": detail})
if not assignment.is_candidate(user) and not assignment.is_elected(user):
raise ValidationError({'detail': _('User %s has no status in this election.') % user})
raise ValidationError(
{"detail": _("User %s has no status in this election.") % user}
)
assignment.delete_related_user(user)
return _('Candidate %s was withdrawn successfully.') % user
return _("Candidate %s was withdrawn successfully.") % user
@detail_route(methods=['post', 'delete'])
@detail_route(methods=["post", "delete"])
def mark_elected(self, request, pk=None):
"""
View to mark other users as elected (POST) or undo this (DELETE).
@ -168,35 +212,41 @@ class AssignmentViewSet(ModelViewSet):
"""
user = self.get_user_from_request_data(request)
assignment = self.get_object()
if request.method == 'POST':
if request.method == "POST":
if not assignment.is_candidate(user):
raise ValidationError({'detail': _('User %s is not a candidate of this election.') % user})
raise ValidationError(
{"detail": _("User %s is not a candidate of this election.") % user}
)
assignment.set_elected(user)
message = _('User %s was successfully elected.') % user
message = _("User %s was successfully elected.") % user
else:
# request.method == 'DELETE'
if not assignment.is_elected(user):
detail = _('User %s is not an elected candidate of this election.') % user
raise ValidationError({'detail': detail})
detail = (
_("User %s is not an elected candidate of this election.") % user
)
raise ValidationError({"detail": detail})
assignment.set_candidate(user)
message = _('User %s was successfully unelected.') % user
return Response({'detail': message})
message = _("User %s was successfully unelected.") % user
return Response({"detail": message})
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def create_poll(self, request, pk=None):
"""
View to create a poll. It is a POST request without any data.
"""
assignment = self.get_object()
if not assignment.candidates.exists():
raise ValidationError({'detail': _('Can not create ballot because there are no candidates.')})
raise ValidationError(
{"detail": _("Can not create ballot because there are no candidates.")}
)
with transaction.atomic():
poll = assignment.create_poll()
return Response({
'detail': _('Ballot created successfully.'),
'createdPollId': poll.pk})
return Response(
{"detail": _("Ballot created successfully."), "createdPollId": poll.pk}
)
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def sort_related_users(self, request, pk=None):
"""
Special view endpoint to sort the assignment related users.
@ -206,22 +256,25 @@ class AssignmentViewSet(ModelViewSet):
assignment = self.get_object()
# Check data
related_user_ids = request.data.get('related_users')
related_user_ids = request.data.get("related_users")
if not isinstance(related_user_ids, list):
raise ValidationError(
{'detail': _('users has to be a list of IDs.')})
raise ValidationError({"detail": _("users has to be a list of IDs.")})
# Get all related users from AssignmentRelatedUser.
related_users = {}
for related_user in AssignmentRelatedUser.objects.filter(assignment__id=assignment.id):
for related_user in AssignmentRelatedUser.objects.filter(
assignment__id=assignment.id
):
related_users[related_user.pk] = related_user
# Check all given candidates from the request
valid_related_users = []
for related_user_id in related_user_ids:
if not isinstance(related_user_id, int) or related_users.get(related_user_id) is None:
raise ValidationError(
{'detail': _('Invalid data.')})
if (
not isinstance(related_user_id, int)
or related_users.get(related_user_id) is None
):
raise ValidationError({"detail": _("Invalid data.")})
valid_related_users.append(related_users[related_user_id])
# Sort the related users
@ -236,7 +289,7 @@ class AssignmentViewSet(ModelViewSet):
inform_changed_data(assignment)
# Initiate response.
return Response({'detail': _('Assignment related users successfully sorted.')})
return Response({"detail": _("Assignment related users successfully sorted.")})
class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
@ -245,6 +298,7 @@ class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet)
There are the following views: update, partial_update and destroy.
"""
queryset = AssignmentPoll.objects.all()
serializer_class = AssignmentAllPollSerializer
@ -252,5 +306,6 @@ class AssignmentPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet)
"""
Returns True if the user has required permissions.
"""
return (has_perm(self.request.user, 'assignments.can_see') and
has_perm(self.request.user, 'assignments.can_manage'))
return has_perm(self.request.user, "assignments.can_see") and has_perm(
self.request.user, "assignments.can_manage"
)

View File

@ -1 +1 @@
default_app_config = 'openslides.core.apps.CoreAppConfig'
default_app_config = "openslides.core.apps.CoreAppConfig"

View File

@ -6,7 +6,8 @@ class ProjectorAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Projector and ProjectorViewSet.
"""
base_permission = 'core.can_see_projector'
base_permission = "core.can_see_projector"
class TagAccessPermissions(BaseAccessPermissions):
@ -19,21 +20,24 @@ class ChatMessageAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for ChatMessage and ChatMessageViewSet.
"""
base_permission = 'core.can_use_chat'
base_permission = "core.can_use_chat"
class ProjectorMessageAccessPermissions(BaseAccessPermissions):
"""
Access permissions for ProjectorMessage.
"""
base_permission = 'core.can_see_projector'
base_permission = "core.can_see_projector"
class CountdownAccessPermissions(BaseAccessPermissions):
"""
Access permissions for Countdown.
"""
base_permission = 'core.can_see_projector'
base_permission = "core.can_see_projector"
class ConfigAccessPermissions(BaseAccessPermissions):

View File

@ -11,8 +11,8 @@ from ..utils.projector import register_projector_elements
class CoreAppConfig(AppConfig):
name = 'openslides.core'
verbose_name = 'OpenSlides Core'
name = "openslides.core"
verbose_name = "OpenSlides Core"
angular_site_module = True
angular_projector_module = True
@ -54,7 +54,7 @@ class CoreAppConfig(AppConfig):
# Skip all database related accesses during migrations.
is_normal_server_start = False
for sys_part in sys.argv:
for entry in ('runserver', 'gunicorn', 'daphne', 'create-example-data'):
for entry in ("runserver", "gunicorn", "daphne", "create-example-data"):
if sys_part.endswith(entry):
is_normal_server_start = True
break
@ -68,27 +68,46 @@ class CoreAppConfig(AppConfig):
# Connect signals.
post_permission_creation.connect(
delete_django_app_permissions,
dispatch_uid='delete_django_app_permissions')
delete_django_app_permissions, dispatch_uid="delete_django_app_permissions"
)
permission_change.connect(
get_permission_change_data,
dispatch_uid='core_get_permission_change_data')
get_permission_change_data, dispatch_uid="core_get_permission_change_data"
)
post_migrate.connect(call_save_default_values, sender=self, dispatch_uid='core_save_config_default_values')
post_migrate.connect(
call_save_default_values,
sender=self,
dispatch_uid="core_save_config_default_values",
)
# Register viewsets.
router.register(self.get_model('Projector').get_collection_string(), ProjectorViewSet)
router.register(self.get_model('ChatMessage').get_collection_string(), ChatMessageViewSet)
router.register(self.get_model('Tag').get_collection_string(), TagViewSet)
router.register(self.get_model('ConfigStore').get_collection_string(), ConfigViewSet, 'config')
router.register(self.get_model('ProjectorMessage').get_collection_string(), ProjectorMessageViewSet)
router.register(self.get_model('Countdown').get_collection_string(), CountdownViewSet)
router.register(self.get_model('History').get_collection_string(), HistoryViewSet)
router.register(
self.get_model("Projector").get_collection_string(), ProjectorViewSet
)
router.register(
self.get_model("ChatMessage").get_collection_string(), ChatMessageViewSet
)
router.register(self.get_model("Tag").get_collection_string(), TagViewSet)
router.register(
self.get_model("ConfigStore").get_collection_string(),
ConfigViewSet,
"config",
)
router.register(
self.get_model("ProjectorMessage").get_collection_string(),
ProjectorMessageViewSet,
)
router.register(
self.get_model("Countdown").get_collection_string(), CountdownViewSet
)
router.register(
self.get_model("History").get_collection_string(), HistoryViewSet
)
# Sets the cache and builds the startup history
if is_normal_server_start:
element_cache.ensure_cache()
self.get_model('History').objects.build_history()
self.get_model("History").objects.build_history()
# Register client messages
register_client_message(NotifyWebsocketClientMessage())
@ -97,10 +116,13 @@ class CoreAppConfig(AppConfig):
register_client_message(AutoupdateWebsocketClientMessage())
# register required_users
required_user.add_collection_string(self.get_model('ChatMessage').get_collection_string(), required_users)
required_user.add_collection_string(
self.get_model("ChatMessage").get_collection_string(), required_users
)
def get_config_variables(self):
from .config_variables import get_config_variables
return get_config_variables()
def get_startup_elements(self):
@ -108,7 +130,15 @@ class CoreAppConfig(AppConfig):
Yields all Cachables required on startup i. e. opening the websocket
connection.
"""
for model_name in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown', 'ConfigStore', 'History'):
for model_name in (
"Projector",
"ChatMessage",
"Tag",
"ProjectorMessage",
"Countdown",
"ConfigStore",
"History",
):
yield self.get_model(model_name)
def get_angular_constants(self):
@ -118,9 +148,9 @@ class CoreAppConfig(AppConfig):
# Client settings
client_settings_keys = [
'MOTION_IDENTIFIER_MIN_DIGITS',
'MOTION_IDENTIFIER_WITHOUT_BLANKS',
'MOTIONS_ALLOW_AMENDMENTS_OF_AMENDMENTS'
"MOTION_IDENTIFIER_MIN_DIGITS",
"MOTION_IDENTIFIER_WITHOUT_BLANKS",
"MOTIONS_ALLOW_AMENDMENTS_OF_AMENDMENTS",
]
client_settings_dict = {}
for key in client_settings_keys:
@ -130,33 +160,40 @@ class CoreAppConfig(AppConfig):
# Settings key does not exist. Do nothing. The client will
# treat this as undefined.
pass
constants['OpenSlidesSettings'] = client_settings_dict
constants["OpenSlidesSettings"] = client_settings_dict
# Config variables
config_groups: List[Any] = []
for config_variable in sorted(config.config_variables.values(), key=attrgetter('weight')):
for config_variable in sorted(
config.config_variables.values(), key=attrgetter("weight")
):
if config_variable.is_hidden():
# Skip hidden config variables. Do not even check groups and subgroups.
continue
if not config_groups or config_groups[-1]['name'] != config_variable.group:
if not config_groups or config_groups[-1]["name"] != config_variable.group:
# Add new group.
config_groups.append(OrderedDict(
name=config_variable.group,
subgroups=[]))
if not config_groups[-1]['subgroups'] or config_groups[-1]['subgroups'][-1]['name'] != config_variable.subgroup:
config_groups.append(
OrderedDict(name=config_variable.group, subgroups=[])
)
if (
not config_groups[-1]["subgroups"]
or config_groups[-1]["subgroups"][-1]["name"]
!= config_variable.subgroup
):
# Add new subgroup.
config_groups[-1]['subgroups'].append(OrderedDict(
name=config_variable.subgroup,
items=[]))
config_groups[-1]["subgroups"].append(
OrderedDict(name=config_variable.subgroup, items=[])
)
# Add the config variable to the current group and subgroup.
config_groups[-1]['subgroups'][-1]['items'].append(config_variable.data)
constants['OpenSlidesConfigVariables'] = config_groups
config_groups[-1]["subgroups"][-1]["items"].append(config_variable.data)
constants["OpenSlidesConfigVariables"] = config_groups
return constants
def call_save_default_values(**kwargs):
from .config import config
config.save_default_values()
@ -164,4 +201,4 @@ def required_users(element: Dict[str, Any]) -> Set[int]:
"""
Returns all user ids that are displayed as chatters.
"""
return set(element['user_id'])
return set(element["user_id"])

View File

@ -1,13 +1,4 @@
from typing import (
Any,
Callable,
Dict,
Iterable,
Optional,
TypeVar,
Union,
cast,
)
from typing import Any, Callable, Dict, Iterable, Optional, TypeVar, Union, cast
from asgiref.sync import async_to_sync
from django.apps import apps
@ -21,16 +12,16 @@ from .models import ConfigStore
INPUT_TYPE_MAPPING = {
'string': str,
'text': str,
'markupText': str,
'integer': int,
'boolean': bool,
'choice': str,
'colorpicker': str,
'datetimepicker': int,
'static': dict,
'translations': list,
"string": str,
"text": str,
"markupText": str,
"integer": int,
"boolean": bool,
"choice": str,
"colorpicker": str,
"datetimepicker": int,
"static": dict,
"translations": list,
}
@ -54,9 +45,11 @@ class ConfigHandler:
Returns the value of the config variable.
"""
if not self.exists(key):
raise ConfigNotFound(_('The config variable {} was not found.').format(key))
raise ConfigNotFound(_("The config variable {} was not found.").format(key))
return async_to_sync(element_cache.get_element_full_data)(self.get_collection_string(), self.get_key_to_id()[key])['value']
return async_to_sync(element_cache.get_element_full_data)(
self.get_collection_string(), self.get_key_to_id()[key]
)["value"]
def get_key_to_id(self) -> Dict[str, int]:
"""
@ -80,7 +73,7 @@ class ConfigHandler:
all_data = await element_cache.get_all_full_data()
elements = all_data[self.get_collection_string()]
for element in elements:
self.key_to_id[element['key']] = element['id']
self.key_to_id[element["key"]] = element["id"]
def exists(self, key: str) -> bool:
"""
@ -102,7 +95,7 @@ class ConfigHandler:
try:
config_variable = self.config_variables[key]
except KeyError:
raise ConfigNotFound(_('The config variable {} was not found.').format(key))
raise ConfigNotFound(_("The config variable {} was not found.").format(key))
# Validate datatype and run validators.
expected_type = INPUT_TYPE_MAPPING[config_variable.input_type]
@ -111,17 +104,21 @@ class ConfigHandler:
try:
value = expected_type(value)
except ValueError:
raise ConfigError(_('Wrong datatype. Expected %(expected_type)s, got %(got_type)s.') % {
'expected_type': expected_type, 'got_type': type(value)})
raise ConfigError(
_("Wrong datatype. Expected %(expected_type)s, got %(got_type)s.")
% {"expected_type": expected_type, "got_type": type(value)}
)
if config_variable.input_type == 'choice':
if config_variable.input_type == "choice":
# Choices can be a callable. In this case call it at this place
if callable(config_variable.choices):
choices = config_variable.choices()
else:
choices = config_variable.choices
if choices is None or value not in map(lambda choice: choice['value'], choices):
raise ConfigError(_('Invalid input. Choice does not match.'))
if choices is None or value not in map(
lambda choice: choice["value"], choices
):
raise ConfigError(_("Invalid input. Choice does not match."))
for validator in config_variable.validators:
try:
@ -129,34 +126,36 @@ class ConfigHandler:
except DjangoValidationError as e:
raise ConfigError(e.messages[0])
if config_variable.input_type == 'static':
if config_variable.input_type == "static":
if not isinstance(value, dict):
raise ConfigError(_('This has to be a dict.'))
whitelist = (
'path',
'display_name',
)
raise ConfigError(_("This has to be a dict."))
whitelist = ("path", "display_name")
for required_entry in whitelist:
if required_entry not in value:
raise ConfigError(_('{} has to be given.'.format(required_entry)))
raise ConfigError(_("{} has to be given.".format(required_entry)))
if not isinstance(value[required_entry], str):
raise ConfigError(_('{} has to be a string.'.format(required_entry)))
raise ConfigError(
_("{} has to be a string.".format(required_entry))
)
if config_variable.input_type == 'translations':
if config_variable.input_type == "translations":
if not isinstance(value, list):
raise ConfigError(_('Translations has to be a list.'))
raise ConfigError(_("Translations has to be a list."))
for entry in value:
if not isinstance(entry, dict):
raise ConfigError(_('Every value has to be a dict, not {}.'.format(type(entry))))
whitelist = (
'original',
'translation',
)
raise ConfigError(
_("Every value has to be a dict, not {}.".format(type(entry)))
)
whitelist = ("original", "translation")
for required_entry in whitelist:
if required_entry not in entry:
raise ConfigError(_('{} has to be given.'.format(required_entry)))
raise ConfigError(
_("{} has to be given.".format(required_entry))
)
if not isinstance(entry[required_entry], str):
raise ConfigError(_('{} has to be a string.'.format(required_entry)))
raise ConfigError(
_("{} has to be a string.".format(required_entry))
)
# Save the new value to the database.
db_value = ConfigStore.objects.get(key=key)
@ -178,7 +177,7 @@ class ConfigHandler:
continue
self.update_config_variables(get_config_variables())
def update_config_variables(self, items: Iterable['ConfigVariable']) -> None:
def update_config_variables(self, items: Iterable["ConfigVariable"]) -> None:
"""
Updates the config_variables dict.
"""
@ -189,7 +188,9 @@ class ConfigHandler:
# be in already in self.config_variables
intersection = set(item_index.keys()).intersection(self.config_variables.keys())
if intersection:
raise ConfigError(_('Too many values for config variables {} found.').format(intersection))
raise ConfigError(
_("Too many values for config variables {} found.").format(intersection)
)
self.config_variables.update(item_index)
@ -224,19 +225,22 @@ use x = config[...], to set it use config[...] = x.
"""
T = TypeVar('T')
T = TypeVar("T")
ChoiceType = Optional[Iterable[Dict[str, str]]]
ChoiceCallableType = Union[ChoiceType, Callable[[], ChoiceType]]
ValidatorsType = Iterable[Callable[[T], None]]
OnChangeType = Callable[[], None]
ConfigVariableDict = TypedDict('ConfigVariableDict', {
'key': str,
'default_value': Any,
'input_type': str,
'label': str,
'help_text': str,
'choices': ChoiceType,
})
ConfigVariableDict = TypedDict(
"ConfigVariableDict",
{
"key": str,
"default_value": Any,
"input_type": str,
"label": str,
"help_text": str,
"choices": ChoiceType,
},
)
class ConfigVariable:
@ -265,27 +269,47 @@ class ConfigVariable:
the value during setup of the database if the admin uses the respective
command line option.
"""
def __init__(self, name: str, default_value: T, input_type: str = 'string',
label: str = None, help_text: str = None, choices: ChoiceCallableType = None,
hidden: bool = False, weight: int = 0, group: str = None, subgroup: str = None,
validators: ValidatorsType = None, on_change: OnChangeType = None) -> None:
def __init__(
self,
name: str,
default_value: T,
input_type: str = "string",
label: str = None,
help_text: str = None,
choices: ChoiceCallableType = None,
hidden: bool = False,
weight: int = 0,
group: str = None,
subgroup: str = None,
validators: ValidatorsType = None,
on_change: OnChangeType = None,
) -> None:
if input_type not in INPUT_TYPE_MAPPING:
raise ValueError(_('Invalid value for config attribute input_type.'))
if input_type == 'choice' and choices is None:
raise ConfigError(_("Either config attribute 'choices' must not be None or "
"'input_type' must not be 'choice'."))
elif input_type != 'choice' and choices is not None:
raise ConfigError(_("Either config attribute 'choices' must be None or "
"'input_type' must be 'choice'."))
raise ValueError(_("Invalid value for config attribute input_type."))
if input_type == "choice" and choices is None:
raise ConfigError(
_(
"Either config attribute 'choices' must not be None or "
"'input_type' must not be 'choice'."
)
)
elif input_type != "choice" and choices is not None:
raise ConfigError(
_(
"Either config attribute 'choices' must be None or "
"'input_type' must be 'choice'."
)
)
self.name = name
self.default_value = default_value
self.input_type = input_type
self.label = label or name
self.help_text = help_text or ''
self.help_text = help_text or ""
self.choices = choices
self.hidden = hidden
self.weight = weight
self.group = group or _('General')
self.group = group or _("General")
self.subgroup = subgroup
self.validators = validators or ()
self.on_change = on_change
@ -301,7 +325,7 @@ class ConfigVariable:
input_type=self.input_type,
label=self.label,
help_text=self.help_text,
choices=self.choices() if callable(self.choices) else self.choices
choices=self.choices() if callable(self.choices) else self.choices,
)
def is_hidden(self) -> bool:

View File

@ -12,401 +12,429 @@ def get_config_variables():
(see apps.py).
"""
yield ConfigVariable(
name='general_event_name',
default_value='OpenSlides',
label='Event name',
name="general_event_name",
default_value="OpenSlides",
label="Event name",
weight=110,
group='General',
subgroup='Event',
validators=(MaxLengthValidator(100),))
group="General",
subgroup="Event",
validators=(MaxLengthValidator(100),),
)
yield ConfigVariable(
name='general_event_description',
default_value='Presentation and assembly system',
label='Short description of event',
name="general_event_description",
default_value="Presentation and assembly system",
label="Short description of event",
weight=115,
group='General',
subgroup='Event',
validators=(MaxLengthValidator(100),))
group="General",
subgroup="Event",
validators=(MaxLengthValidator(100),),
)
yield ConfigVariable(
name='general_event_date',
default_value='',
label='Event date',
name="general_event_date",
default_value="",
label="Event date",
weight=120,
group='General',
subgroup='Event')
group="General",
subgroup="Event",
)
yield ConfigVariable(
name='general_event_location',
default_value='',
label='Event location',
name="general_event_location",
default_value="",
label="Event location",
weight=125,
group='General',
subgroup='Event')
group="General",
subgroup="Event",
)
yield ConfigVariable(
name='general_event_legal_notice',
name="general_event_legal_notice",
default_value='<a href="http://www.openslides.org">OpenSlides</a> is a '
'free web based presentation and assembly system for '
'visualizing and controlling agenda, motions and '
'elections of an assembly.',
input_type='markupText',
label='Legal notice',
"free web based presentation and assembly system for "
"visualizing and controlling agenda, motions and "
"elections of an assembly.",
input_type="markupText",
label="Legal notice",
weight=132,
group='General',
subgroup='Event')
group="General",
subgroup="Event",
)
yield ConfigVariable(
name='general_event_privacy_policy',
default_value='',
input_type='markupText',
label='Privacy policy',
name="general_event_privacy_policy",
default_value="",
input_type="markupText",
label="Privacy policy",
weight=132,
group='General',
subgroup='Event')
group="General",
subgroup="Event",
)
yield ConfigVariable(
name='general_event_welcome_title',
default_value='Welcome to OpenSlides',
label='Front page title',
name="general_event_welcome_title",
default_value="Welcome to OpenSlides",
label="Front page title",
weight=134,
group='General',
subgroup='Event')
group="General",
subgroup="Event",
)
yield ConfigVariable(
name='general_event_welcome_text',
default_value='[Space for your welcome text.]',
input_type='markupText',
label='Front page text',
name="general_event_welcome_text",
default_value="[Space for your welcome text.]",
input_type="markupText",
label="Front page text",
weight=136,
group='General',
subgroup='Event')
group="General",
subgroup="Event",
)
# General System
yield ConfigVariable(
name='general_system_enable_anonymous',
name="general_system_enable_anonymous",
default_value=False,
input_type='boolean',
label='Allow access for anonymous guest users',
input_type="boolean",
label="Allow access for anonymous guest users",
weight=138,
group='General',
subgroup='System')
group="General",
subgroup="System",
)
yield ConfigVariable(
name='general_login_info_text',
default_value='',
label='Show this text on the login page',
name="general_login_info_text",
default_value="",
label="Show this text on the login page",
weight=140,
group='General',
subgroup='System')
group="General",
subgroup="System",
)
# General export settings
yield ConfigVariable(
name='general_csv_separator',
default_value=',',
label='Separator used for all csv exports and examples',
name="general_csv_separator",
default_value=",",
label="Separator used for all csv exports and examples",
weight=142,
group='General',
subgroup='Export')
group="General",
subgroup="Export",
)
yield ConfigVariable(
name='general_export_pdf_pagenumber_alignment',
default_value='center',
input_type='choice',
label='Page number alignment in PDF',
name="general_export_pdf_pagenumber_alignment",
default_value="center",
input_type="choice",
label="Page number alignment in PDF",
choices=(
{'value': 'left', 'display_name': 'Left'},
{'value': 'center', 'display_name': 'Center'},
{'value': 'right', 'display_name': 'Right'}),
{"value": "left", "display_name": "Left"},
{"value": "center", "display_name": "Center"},
{"value": "right", "display_name": "Right"},
),
weight=144,
group='General',
subgroup='Export')
group="General",
subgroup="Export",
)
yield ConfigVariable(
name='general_export_pdf_fontsize',
default_value='10',
input_type='choice',
label='Standard font size in PDF',
name="general_export_pdf_fontsize",
default_value="10",
input_type="choice",
label="Standard font size in PDF",
choices=(
{'value': '10', 'display_name': '10'},
{'value': '11', 'display_name': '11'},
{'value': '12', 'display_name': '12'}),
{"value": "10", "display_name": "10"},
{"value": "11", "display_name": "11"},
{"value": "12", "display_name": "12"},
),
weight=146,
group='General',
subgroup='Export')
group="General",
subgroup="Export",
)
# Projector
yield ConfigVariable(
name='projector_language',
default_value='browser',
input_type='choice',
label='Projector language',
name="projector_language",
default_value="browser",
input_type="choice",
label="Projector language",
choices=(
{'value': 'browser', 'display_name': 'Current browser language'},
{'value': 'en', 'display_name': 'English'},
{'value': 'de', 'display_name': 'Deutsch'},
{'value': 'fr', 'display_name': 'Français'},
{'value': 'es', 'display_name': 'Español'},
{'value': 'pt', 'display_name': 'Português'},
{'value': 'cs', 'display_name': 'Čeština'},
{'value': 'ru', 'display_name': 'русский'}),
{"value": "browser", "display_name": "Current browser language"},
{"value": "en", "display_name": "English"},
{"value": "de", "display_name": "Deutsch"},
{"value": "fr", "display_name": "Français"},
{"value": "es", "display_name": "Español"},
{"value": "pt", "display_name": "Português"},
{"value": "cs", "display_name": "Čeština"},
{"value": "ru", "display_name": "русский"},
),
weight=150,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_enable_logo',
name="projector_enable_logo",
default_value=True,
input_type='boolean',
label='Show logo on projector',
help_text='You can replace the logo by uploading an image and set it as '
'the "Projector logo" in "files".',
input_type="boolean",
label="Show logo on projector",
help_text="You can replace the logo by uploading an image and set it as "
'the "Projector logo" in "files".',
weight=152,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_enable_clock',
name="projector_enable_clock",
default_value=True,
input_type='boolean',
label='Show the clock on projector',
input_type="boolean",
label="Show the clock on projector",
weight=154,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_enable_title',
name="projector_enable_title",
default_value=True,
input_type='boolean',
label='Show title and description of event on projector',
input_type="boolean",
label="Show title and description of event on projector",
weight=155,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_enable_header_footer',
name="projector_enable_header_footer",
default_value=True,
input_type='boolean',
label='Display header and footer',
input_type="boolean",
label="Display header and footer",
weight=157,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_header_backgroundcolor',
default_value='#317796',
input_type='colorpicker',
label='Background color of projector header and footer',
name="projector_header_backgroundcolor",
default_value="#317796",
input_type="colorpicker",
label="Background color of projector header and footer",
weight=160,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_header_fontcolor',
default_value='#F5F5F5',
input_type='colorpicker',
label='Font color of projector header and footer',
name="projector_header_fontcolor",
default_value="#F5F5F5",
input_type="colorpicker",
label="Font color of projector header and footer",
weight=165,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_h1_fontcolor',
default_value='#317796',
input_type='colorpicker',
label='Font color of projector headline',
name="projector_h1_fontcolor",
default_value="#317796",
input_type="colorpicker",
label="Font color of projector headline",
weight=170,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_default_countdown',
name="projector_default_countdown",
default_value=60,
input_type='integer',
label='Predefined seconds of new countdowns',
input_type="integer",
label="Predefined seconds of new countdowns",
weight=185,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_blank_color',
default_value='#FFFFFF',
input_type='colorpicker',
label='Color for blanked projector',
name="projector_blank_color",
default_value="#FFFFFF",
input_type="colorpicker",
label="Color for blanked projector",
weight=190,
group='Projector')
group="Projector",
)
yield ConfigVariable(
name='projector_broadcast',
name="projector_broadcast",
default_value=0,
input_type='integer',
label='Projector which is broadcasted',
input_type="integer",
label="Projector which is broadcasted",
weight=200,
group='Projector',
hidden=True)
group="Projector",
hidden=True,
)
yield ConfigVariable(
name='projector_currentListOfSpeakers_reference',
name="projector_currentListOfSpeakers_reference",
default_value=1,
input_type='integer',
label='Projector reference for list of speakers',
input_type="integer",
label="Projector reference for list of speakers",
weight=201,
group='Projector',
hidden=True)
group="Projector",
hidden=True,
)
# Logos.
yield ConfigVariable(
name='logos_available',
name="logos_available",
default_value=[
'logo_projector_main',
'logo_projector_header',
'logo_web_header',
'logo_pdf_header_L',
'logo_pdf_header_R',
'logo_pdf_footer_L',
'logo_pdf_footer_R',
'logo_pdf_ballot_paper'],
"logo_projector_main",
"logo_projector_header",
"logo_web_header",
"logo_pdf_header_L",
"logo_pdf_header_R",
"logo_pdf_footer_L",
"logo_pdf_footer_R",
"logo_pdf_ballot_paper",
],
weight=300,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
yield ConfigVariable(
name='logo_projector_main',
default_value={
'display_name': 'Projector logo',
'path': ''},
input_type='static',
name="logo_projector_main",
default_value={"display_name": "Projector logo", "path": ""},
input_type="static",
weight=301,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
yield ConfigVariable(
name='logo_projector_header',
default_value={
'display_name': 'Projector header image',
'path': ''},
input_type='static',
name="logo_projector_header",
default_value={"display_name": "Projector header image", "path": ""},
input_type="static",
weight=302,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
yield ConfigVariable(
name='logo_web_header',
default_value={
'display_name': 'Web interface header logo',
'path': ''},
input_type='static',
name="logo_web_header",
default_value={"display_name": "Web interface header logo", "path": ""},
input_type="static",
weight=303,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
# PDF logos
yield ConfigVariable(
name='logo_pdf_header_L',
default_value={
'display_name': 'PDF header logo (left)',
'path': ''},
input_type='static',
name="logo_pdf_header_L",
default_value={"display_name": "PDF header logo (left)", "path": ""},
input_type="static",
weight=310,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
yield ConfigVariable(
name='logo_pdf_header_R',
default_value={
'display_name': 'PDF header logo (right)',
'path': ''},
input_type='static',
name="logo_pdf_header_R",
default_value={"display_name": "PDF header logo (right)", "path": ""},
input_type="static",
weight=311,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
yield ConfigVariable(
name='logo_pdf_footer_L',
default_value={
'display_name': 'PDF footer logo (left)',
'path': ''},
input_type='static',
name="logo_pdf_footer_L",
default_value={"display_name": "PDF footer logo (left)", "path": ""},
input_type="static",
weight=312,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
yield ConfigVariable(
name='logo_pdf_footer_R',
default_value={
'display_name': 'PDF footer logo (right)',
'path': ''},
input_type='static',
name="logo_pdf_footer_R",
default_value={"display_name": "PDF footer logo (right)", "path": ""},
input_type="static",
weight=313,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
yield ConfigVariable(
name='logo_pdf_ballot_paper',
default_value={
'display_name': 'PDF ballot paper logo',
'path': ''},
input_type='static',
name="logo_pdf_ballot_paper",
default_value={"display_name": "PDF ballot paper logo", "path": ""},
input_type="static",
weight=314,
group='Logo',
hidden=True)
group="Logo",
hidden=True,
)
# Fonts
yield ConfigVariable(
name='fonts_available',
default_value=[
'font_regular',
'font_italic',
'font_bold',
'font_bold_italic'],
name="fonts_available",
default_value=["font_regular", "font_italic", "font_bold", "font_bold_italic"],
weight=320,
group='Font',
hidden=True)
group="Font",
hidden=True,
)
yield ConfigVariable(
name='font_regular',
name="font_regular",
default_value={
'display_name': 'Font regular',
'default': 'static/fonts/Roboto-Regular.woff',
'path': ''},
input_type='static',
"display_name": "Font regular",
"default": "static/fonts/Roboto-Regular.woff",
"path": "",
},
input_type="static",
weight=321,
group='Font',
hidden=True)
group="Font",
hidden=True,
)
yield ConfigVariable(
name='font_italic',
name="font_italic",
default_value={
'display_name': 'Font italic',
'default': 'static/fonts/Roboto-Medium.woff',
'path': ''},
input_type='static',
"display_name": "Font italic",
"default": "static/fonts/Roboto-Medium.woff",
"path": "",
},
input_type="static",
weight=321,
group='Font',
hidden=True)
group="Font",
hidden=True,
)
yield ConfigVariable(
name='font_bold',
name="font_bold",
default_value={
'display_name': 'Font bold',
'default': 'static/fonts/Roboto-Condensed-Regular.woff',
'path': ''},
input_type='static',
"display_name": "Font bold",
"default": "static/fonts/Roboto-Condensed-Regular.woff",
"path": "",
},
input_type="static",
weight=321,
group='Font',
hidden=True)
group="Font",
hidden=True,
)
yield ConfigVariable(
name='font_bold_italic',
name="font_bold_italic",
default_value={
'display_name': 'Font bold italic',
'default': 'static/fonts/Roboto-Condensed-Light.woff',
'path': ''},
input_type='static',
"display_name": "Font bold italic",
"default": "static/fonts/Roboto-Condensed-Light.woff",
"path": "",
},
input_type="static",
weight=321,
group='Font',
hidden=True)
group="Font",
hidden=True,
)
# Custom translations
yield ConfigVariable(
name='translations',
label='Custom translations',
name="translations",
label="Custom translations",
default_value=[],
input_type='translations',
input_type="translations",
weight=1000,
group='Custom translations')
group="Custom translations",
)

View File

@ -10,17 +10,18 @@ class Command(BaseCommand):
"""
Command to backup the SQLite3 database.
"""
help = 'Backups the SQLite3 database.'
help = "Backups the SQLite3 database."
def add_arguments(self, parser):
parser.add_argument(
'--path',
default='database_backup.sqlite',
help='Path for the backup file (Default: database_backup.sqlite).'
"--path",
default="database_backup.sqlite",
help="Path for the backup file (Default: database_backup.sqlite).",
)
def handle(self, *args, **options):
path = options.get('path')
path = options.get("path")
@transaction.atomic
def do_backup(src_path, dest_path):
@ -39,8 +40,11 @@ class Command(BaseCommand):
database_path = get_database_path_from_settings()
if database_path:
do_backup(database_path, path)
self.stdout.write('Database %s successfully stored at %s.' % (database_path, path))
self.stdout.write(
"Database %s successfully stored at %s." % (database_path, path)
)
else:
raise CommandError(
'Default database is not SQLite3. Only SQLite3 databases'
'can currently be backuped.')
"Default database is not SQLite3. Only SQLite3 databases"
"can currently be backuped."
)

View File

@ -8,25 +8,28 @@ class Command(BaseCommand):
"""
Command to change OpenSlides config values.
"""
help = 'Changes OpenSlides config values.'
help = "Changes OpenSlides config values."
def add_arguments(self, parser):
parser.add_argument(
'key',
help='Config key. See config_variables.py in every app.'
"key", help="Config key. See config_variables.py in every app."
)
parser.add_argument(
'value',
help='New config value. For a falsy boolean use "False".'
"value", help='New config value. For a falsy boolean use "False".'
)
def handle(self, *args, **options):
if options['value'].lower() == 'false':
options['value'] = False
if options["value"].lower() == "false":
options["value"] = False
try:
config[options['key']] = options['value']
config[options["key"]] = options["value"]
except (ConfigError, ConfigNotFound) as e:
raise CommandError(str(e))
self.stdout.write(
self.style.SUCCESS('Config {key} successfully changed to {value}.'.format(
key=options['key'], value=config[options['key']])))
self.style.SUCCESS(
"Config {key} successfully changed to {value}.".format(
key=options["key"], value=config[options["key"]]
)
)
)

View File

@ -7,19 +7,16 @@ class Command(BaseCommand):
"""
Command to change a user's password.
"""
help = 'Changes user password.'
help = "Changes user password."
def add_arguments(self, parser):
parser.add_argument(
'username',
help='The name of the user to set the password for'
)
parser.add_argument(
'password',
help='The new password of the user'
"username", help="The name of the user to set the password for"
)
parser.add_argument("password", help="The new password of the user")
def handle(self, *args, **options):
user = User.objects.get(username=options['username'])
user.set_password(options['password'])
user = User.objects.get(username=options["username"])
user.set_password(options["password"])
user.save()

View File

@ -10,10 +10,12 @@ class Command(_Command):
Migration command that does nearly the same as Django's migration command
but also calls the post_permission_creation signal.
"""
def handle(self, *args, **options):
from django.conf import settings
# Creates the folder for a SQLite3 database if necessary.
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3':
if settings.DATABASES["default"]["ENGINE"] == "django.db.backends.sqlite3":
try:
os.makedirs(settings.OPENSLIDES_USER_DATA_PATH)
except (FileExistsError, AttributeError):

View File

@ -18,11 +18,9 @@ def add_default_projector(apps, schema_editor):
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
Projector = apps.get_model('core', 'Projector')
Projector = apps.get_model("core", "Projector")
projector_config = {}
projector_config[uuid.uuid4().hex] = {
'name': 'core/clock',
'stable': True}
projector_config[uuid.uuid4().hex] = {"name": "core/clock", "stable": True}
# We use bulk_create here because we do not want model's save() method
# to be called because we do not want our autoupdate signals to be
# triggered.
@ -34,79 +32,126 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('mediafiles', '0001_initial'),
("mediafiles", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ChatMessage',
name="ChatMessage",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("message", models.TextField()),
("timestamp", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'default_permissions': (),
'permissions': (('can_use_chat', 'Can use the chat'),),
"default_permissions": (),
"permissions": (("can_use_chat", "Can use the chat"),),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='ConfigStore',
name="ConfigStore",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, max_length=255, unique=True)),
('value', jsonfield.fields.JSONField()),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(db_index=True, max_length=255, unique=True)),
("value", jsonfield.fields.JSONField()),
],
options={
'default_permissions': (),
'permissions': (('can_manage_config', 'Can manage configuration'),),
"default_permissions": (),
"permissions": (("can_manage_config", "Can manage configuration"),),
},
),
migrations.CreateModel(
name='CustomSlide',
name="CustomSlide",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=256)),
('text', models.TextField(blank=True)),
('weight', models.IntegerField(default=0)),
('attachments', models.ManyToManyField(blank=True, to='mediafiles.Mediafile')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=256)),
("text", models.TextField(blank=True)),
("weight", models.IntegerField(default=0)),
(
"attachments",
models.ManyToManyField(blank=True, to="mediafiles.Mediafile"),
),
],
options={"default_permissions": (), "ordering": ("weight", "title")},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name="Projector",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("config", jsonfield.fields.JSONField()),
("scale", models.IntegerField(default=0)),
("scroll", models.IntegerField(default=0)),
],
options={
'default_permissions': (),
'ordering': ('weight', 'title'),
"default_permissions": (),
"permissions": (
("can_see_projector", "Can see the projector"),
("can_manage_projector", "Can manage the projector"),
("can_see_frontpage", "Can see the front page"),
),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='Projector',
name="Tag",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('config', jsonfield.fields.JSONField()),
('scale', models.IntegerField(default=0)),
('scroll', models.IntegerField(default=0)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, unique=True)),
],
options={
'default_permissions': (),
'permissions': (
('can_see_projector', 'Can see the projector'),
('can_manage_projector', 'Can manage the projector'),
('can_see_frontpage', 'Can see the front page')),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
],
options={
'default_permissions': (),
'permissions': (('can_manage_tags', 'Can manage tags'),),
'ordering': ('name',),
"default_permissions": (),
"permissions": (("can_manage_tags", "Can manage tags"),),
"ordering": ("name",),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),

View File

@ -15,10 +15,10 @@ def move_custom_slides_to_topics(apps, schema_editor):
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
ContentType = apps.get_model('contenttypes', 'ContentType')
CustomSlide = apps.get_model('core', 'CustomSlide')
Item = apps.get_model('agenda', 'Item')
Topic = apps.get_model('topics', 'Topic')
ContentType = apps.get_model("contenttypes", "ContentType")
CustomSlide = apps.get_model("core", "CustomSlide")
Item = apps.get_model("agenda", "Item")
Topic = apps.get_model("topics", "Topic")
# Copy data.
content_type_custom_slide = ContentType.objects.get_for_model(CustomSlide)
@ -28,7 +28,9 @@ def move_custom_slides_to_topics(apps, schema_editor):
# no method 'get_agenda_title()'. See agenda/signals.py.
topic = Topic.objects.create(title=custom_slide.title, text=custom_slide.text)
topic.attachments.add(*custom_slide.attachments.all())
item = Item.objects.get(object_id=custom_slide.pk, content_type=content_type_custom_slide)
item = Item.objects.get(
object_id=custom_slide.pk, content_type=content_type_custom_slide
)
item.object_id = topic.pk
item.content_type = content_type_topic
item.save(skip_autoupdate=True)
@ -42,20 +44,20 @@ def name_default_projector(apps, schema_editor):
"""
Set the name of the default projector to 'Defaultprojector'
"""
Projector = apps.get_model('core', 'Projector')
Projector.objects.filter(pk=1).update(name='Default projector')
Projector = apps.get_model("core", "Projector")
Projector.objects.filter(pk=1).update(name="Default projector")
def remove_old_countdowns_messages(apps, schema_editor):
"""
Remove old countdowns and messages created by 2.0 from projector elements which are unusable in 2.1.
"""
Projector = apps.get_model('core', 'Projector')
Projector = apps.get_model("core", "Projector")
projector = Projector.objects.get(pk=1)
projector_config = projector.config
for key, value in list(projector.config.items()):
if value.get('name') in ('core/countdown', 'core/message'):
if value.get("name") in ("core/countdown", "core/message"):
del projector_config[key]
projector.config = projector_config
projector.save(skip_autoupdate=True)
@ -65,57 +67,74 @@ def add_projection_defaults(apps, schema_editor):
"""
Adds projectiondefaults for messages and countdowns.
"""
Projector = apps.get_model('core', 'Projector')
ProjectionDefault = apps.get_model('core', 'ProjectionDefault')
Projector = apps.get_model("core", "Projector")
ProjectionDefault = apps.get_model("core", "ProjectionDefault")
# The default projector (pk=1) is always available.
default_projector = Projector.objects.get(pk=1)
projectiondefaults = []
projectiondefaults.append(ProjectionDefault(
name='agenda_all_items',
display_name='Agenda',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='topics',
display_name='Topics',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='agenda_list_of_speakers',
display_name='List of speakers',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='agenda_current_list_of_speakers',
display_name='Current list of speakers',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='motions',
display_name='Motions',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='motionBlocks',
display_name='Motion blocks',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='assignments',
display_name='Elections',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='users',
display_name='Participants',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='mediafiles',
display_name='Files',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='messages',
display_name='Messages',
projector=default_projector))
projectiondefaults.append(ProjectionDefault(
name='countdowns',
display_name='Countdowns',
projector=default_projector))
projectiondefaults.append(
ProjectionDefault(
name="agenda_all_items", display_name="Agenda", projector=default_projector
)
)
projectiondefaults.append(
ProjectionDefault(
name="topics", display_name="Topics", projector=default_projector
)
)
projectiondefaults.append(
ProjectionDefault(
name="agenda_list_of_speakers",
display_name="List of speakers",
projector=default_projector,
)
)
projectiondefaults.append(
ProjectionDefault(
name="agenda_current_list_of_speakers",
display_name="Current list of speakers",
projector=default_projector,
)
)
projectiondefaults.append(
ProjectionDefault(
name="motions", display_name="Motions", projector=default_projector
)
)
projectiondefaults.append(
ProjectionDefault(
name="motionBlocks",
display_name="Motion blocks",
projector=default_projector,
)
)
projectiondefaults.append(
ProjectionDefault(
name="assignments", display_name="Elections", projector=default_projector
)
)
projectiondefaults.append(
ProjectionDefault(
name="users", display_name="Participants", projector=default_projector
)
)
projectiondefaults.append(
ProjectionDefault(
name="mediafiles", display_name="Files", projector=default_projector
)
)
projectiondefaults.append(
ProjectionDefault(
name="messages", display_name="Messages", projector=default_projector
)
)
projectiondefaults.append(
ProjectionDefault(
name="countdowns", display_name="Countdowns", projector=default_projector
)
)
# Create all new projectiondefaults
ProjectionDefault.objects.bulk_create(projectiondefaults)
@ -125,114 +144,141 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('sessions', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
('core', '0001_initial'),
('agenda', '0001_initial'), # ('agenda', '0002_item_duration') is not required but would be also ok.
('topics', '0001_initial'),
("sessions", "0001_initial"),
("contenttypes", "0002_remove_content_type_name"),
("core", "0001_initial"),
(
"agenda",
"0001_initial",
), # ('agenda', '0002_item_duration') is not required but would be also ok.
("topics", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='Countdown',
name="Countdown",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(blank=True, max_length=256)),
('running', models.BooleanField(default=False)),
('default_time', models.PositiveIntegerField(default=60)),
('countdown_time', models.FloatField(default=60)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("description", models.CharField(blank=True, max_length=256)),
("running", models.BooleanField(default=False)),
("default_time", models.PositiveIntegerField(default=60)),
("countdown_time", models.FloatField(default=60)),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='ProjectionDefault',
name="ProjectionDefault",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=256)),
('display_name', models.CharField(max_length=256)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=256)),
("display_name", models.CharField(max_length=256)),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='ProjectorMessage',
name="ProjectorMessage",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField(blank=True)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("message", models.TextField(blank=True)),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='Session',
name="Session",
fields=[
('session_ptr', models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to='sessions.Session')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
(
"session_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="sessions.Session",
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'default_permissions': (),
},
bases=('sessions.session',),
),
migrations.RunPython(
move_custom_slides_to_topics
),
migrations.RemoveField(
model_name='customslide',
name='attachments',
),
migrations.DeleteModel(
name='CustomSlide',
options={"default_permissions": ()},
bases=("sessions.session",),
),
migrations.RunPython(move_custom_slides_to_topics),
migrations.RemoveField(model_name="customslide", name="attachments"),
migrations.DeleteModel(name="CustomSlide"),
migrations.AlterModelOptions(
name='chatmessage',
options={'default_permissions': (), 'permissions': (('can_use_chat', 'Can use the chat'), ('can_manage_chat', 'Can manage the chat'))},
name="chatmessage",
options={
"default_permissions": (),
"permissions": (
("can_use_chat", "Can use the chat"),
("can_manage_chat", "Can manage the chat"),
),
},
),
migrations.AddField(
model_name='projector',
name='blank',
model_name="projector",
name="blank",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='projector',
name='height',
model_name="projector",
name="height",
field=models.PositiveIntegerField(default=915),
),
migrations.AddField(
model_name='projector',
name='name',
model_name="projector",
name="name",
field=models.CharField(blank=True, max_length=255, unique=True),
),
migrations.AddField(
model_name='projector',
name='width',
model_name="projector",
name="width",
field=models.PositiveIntegerField(default=1220),
),
migrations.AddField(
model_name='projectiondefault',
name='projector',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projectiondefaults', to='core.Projector'),
),
migrations.RunPython(
name_default_projector
),
migrations.RunPython(
remove_old_countdowns_messages
),
migrations.RunPython(
add_projection_defaults
model_name="projectiondefault",
name="projector",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="projectiondefaults",
to="core.Projector",
),
),
migrations.RunPython(name_default_projector),
migrations.RunPython(remove_old_countdowns_messages),
migrations.RunPython(add_projection_defaults),
]

View File

@ -11,22 +11,16 @@ def remove_session_content_type(apps, schema_editor):
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
ContentType = apps.get_model('contenttypes', 'ContentType')
Session = apps.get_model('core', 'Session')
ContentType = apps.get_model("contenttypes", "ContentType")
Session = apps.get_model("core", "Session")
ContentType.objects.get_for_model(Session).delete()
class Migration(migrations.Migration):
dependencies = [
('core', '0002_misc_features'),
]
dependencies = [("core", "0002_misc_features")]
operations = [
migrations.RunPython(
remove_session_content_type
),
migrations.DeleteModel(
name='Session',
),
migrations.RunPython(remove_session_content_type),
migrations.DeleteModel(name="Session"),
]

View File

@ -13,25 +13,19 @@ def rename_projector_message_slides(apps, schema_editor):
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
Projector = apps.get_model('core', 'Projector')
Projector = apps.get_model("core", "Projector")
for projector in Projector.objects.all():
new_config = {}
for key, value in projector.config.items():
new_config[key] = value
if value['name'] == 'core/projectormessage':
new_config[key]['name'] = 'core/projector-message'
if value["name"] == "core/projectormessage":
new_config[key]["name"] = "core/projector-message"
projector.config = new_config
projector.save(skip_autoupdate=True)
class Migration(migrations.Migration):
dependencies = [
('core', '0003_auto_20161217_1158'),
]
dependencies = [("core", "0003_auto_20161217_1158")]
operations = [
migrations.RunPython(
rename_projector_message_slides
),
]
operations = [migrations.RunPython(rename_projector_message_slides)]

View File

@ -11,22 +11,26 @@ from openslides.utils.migrations import (
class Migration(migrations.Migration):
dependencies = [
('core', '0004_auto_20170215_1624'),
]
dependencies = [("core", "0004_auto_20170215_1624")]
operations = [
migrations.AlterModelOptions(
name='configstore',
name="configstore",
options={
'default_permissions': (),
'permissions': (
('can_manage_config', 'Can manage configuration'),
('can_manage_logos', 'Can manage logos')
)
"default_permissions": (),
"permissions": (
("can_manage_config", "Can manage configuration"),
("can_manage_logos", "Can manage logos"),
),
},
),
migrations.RunPython(add_permission_to_groups_based_on_existing_permission(
'can_manage_config', 'configstore', 'core', 'can_manage_logos', 'Can manage logos'
)),
migrations.RunPython(
add_permission_to_groups_based_on_existing_permission(
"can_manage_config",
"configstore",
"core",
"can_manage_logos",
"Can manage logos",
)
),
]

View File

@ -7,19 +7,17 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_auto_20170412_1258'),
]
dependencies = [("core", "0005_auto_20170412_1258")]
operations = [
migrations.AlterField(
model_name='projector',
name='height',
model_name="projector",
name="height",
field=models.PositiveIntegerField(default=768),
),
migrations.AlterField(
model_name='projector',
name='width',
model_name="projector",
name="width",
field=models.PositiveIntegerField(default=1024),
),
]

View File

@ -15,7 +15,7 @@ def delete_old_logo_permission(apps, schema_editor):
If this is an old database, the new permission will be created and the old
one deleted. Also it will be assigned to the groups, which had the old permission.
"""
perm = Permission.objects.filter(codename='can_manage_logos')
perm = Permission.objects.filter(codename="can_manage_logos")
if len(perm):
perm = perm.get()
@ -30,9 +30,10 @@ def delete_old_logo_permission(apps, schema_editor):
# Create new permission
perm = Permission.objects.create(
codename='can_manage_logos_and_fonts',
name='Can manage logos and fonts',
content_type=content_type)
codename="can_manage_logos_and_fonts",
name="Can manage logos and fonts",
content_type=content_type,
)
for group in groups:
group.permissions.add(perm)
@ -41,22 +42,18 @@ def delete_old_logo_permission(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('core', '0006_auto_20180123_0903'),
]
dependencies = [("core", "0006_auto_20180123_0903")]
operations = [
migrations.AlterModelOptions(
name='configstore',
name="configstore",
options={
'default_permissions': (),
'permissions': (
('can_manage_config', 'Can manage configuration'),
('can_manage_logos_and_fonts', 'Can manage logos and fonts')
)
"default_permissions": (),
"permissions": (
("can_manage_config", "Can manage configuration"),
("can_manage_logos_and_fonts", "Can manage logos and fonts"),
),
},
),
migrations.RunPython(
delete_old_logo_permission
),
migrations.RunPython(delete_old_logo_permission),
]

View File

@ -11,14 +11,14 @@ def logos_available_default_to_database(apps, schema_editor):
"""
Writes the new default value of the 'logos_available' into the database.
"""
ConfigStore = apps.get_model('core', 'ConfigStore')
ConfigStore = apps.get_model("core", "ConfigStore")
try:
logos_available = ConfigStore.objects.get(key='logos_available')
logos_available = ConfigStore.objects.get(key="logos_available")
except ConfigStore.DoesNotExist:
return # The key is not in the database, nothing to change here
default_value = config.config_variables['logos_available'].default_value
default_value = config.config_variables["logos_available"].default_value
logos_available.value = default_value
logos_available.save()
@ -28,16 +28,16 @@ def move_old_logo_settings(apps, schema_editor):
moves the value of 'logo_pdf_header' to 'logo_pdf_header_L' and the same
for the footer. The old ones are deleted.
"""
ConfigStore = apps.get_model('core', 'ConfigStore')
ConfigStore = apps.get_model("core", "ConfigStore")
for place in ('header', 'footer'):
for place in ("header", "footer"):
try:
logo_pdf = ConfigStore.objects.get(key='logo_pdf_{}'.format(place))
logo_pdf = ConfigStore.objects.get(key="logo_pdf_{}".format(place))
except ConfigStore.DoesNotExist:
continue # The old entry is not in the database, nothing to change here
# The key of the new entry
new_value_key = 'logo_pdf_{}_L'.format(place)
new_value_key = "logo_pdf_{}_L".format(place)
try:
logo_pdf_L = ConfigStore.objects.get(key=new_value_key)
except ConfigStore.DoesNotExist:
@ -45,7 +45,7 @@ def move_old_logo_settings(apps, schema_editor):
logo_pdf_L.value = {}
# Move the path to the new configentry
logo_pdf_L.value['path'] = logo_pdf.value.get('path', '')
logo_pdf_L.value["path"] = logo_pdf.value.get("path", "")
# Save the new one, delete the old
logo_pdf_L.save()
logo_pdf.delete()
@ -53,15 +53,9 @@ def move_old_logo_settings(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('core', '0007_auto_20180130_1400'),
]
dependencies = [("core", "0007_auto_20180130_1400")]
operations = [
migrations.RunPython(
logos_available_default_to_database
),
migrations.RunPython(
move_old_logo_settings
),
migrations.RunPython(logos_available_default_to_database),
migrations.RunPython(move_old_logo_settings),
]

View File

@ -13,42 +13,68 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0008_changed_logo_fields'),
("core", "0008_changed_logo_fields"),
]
operations = [
migrations.CreateModel(
name='History',
name="History",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('element_id', models.CharField(max_length=255)),
('now', models.DateTimeField(auto_now_add=True)),
('information', models.CharField(max_length=255)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("element_id", models.CharField(max_length=255)),
("now", models.DateTimeField(auto_now_add=True)),
("information", models.CharField(max_length=255)),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='HistoryData',
name="HistoryData",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_data', jsonfield.fields.JSONField(
dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'separators': (',', ':')}, load_kwargs={})),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"full_data",
jsonfield.fields.JSONField(
dump_kwargs={
"cls": jsonfield.encoder.JSONEncoder,
"separators": (",", ":"),
},
load_kwargs={},
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
),
migrations.AddField(
model_name='history',
name='full_data',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='core.HistoryData'),
model_name="history",
name="full_data",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to="core.HistoryData"
),
),
migrations.AddField(
model_name='history',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
model_name="history",
name="user",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -24,13 +24,13 @@ class ProjectorManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all projectors. In the background
projector defaults are prefetched from the database.
"""
return self.get_queryset().prefetch_related(
'projectiondefaults')
return self.get_queryset().prefetch_related("projectiondefaults")
class Projector(RESTModelMixin, models.Model):
@ -72,6 +72,7 @@ class Projector(RESTModelMixin, models.Model):
The projector can be controlled using the REST API with POST requests
on e. g. the URL /rest/core/projector/1/activate_elements/.
"""
access_permissions = ProjectorAccessPermissions()
objects = ProjectorManager()
@ -86,24 +87,21 @@ class Projector(RESTModelMixin, models.Model):
height = models.PositiveIntegerField(default=768)
name = models.CharField(
max_length=255,
unique=True,
blank=True)
name = models.CharField(max_length=255, unique=True, blank=True)
blank = models.BooleanField(
blank=False,
default=False)
blank = models.BooleanField(blank=False, default=False)
class Meta:
"""
Contains general permissions that can not be placed in a specific app.
"""
default_permissions = ()
permissions = (
('can_see_projector', 'Can see the projector'),
('can_manage_projector', 'Can manage the projector'),
('can_see_frontpage', 'Can see the front page'),)
("can_see_projector", "Can see the projector"),
("can_manage_projector", "Can manage the projector"),
("can_see_frontpage", "Can see the front page"),
)
@property
def elements(self):
@ -120,17 +118,19 @@ class Projector(RESTModelMixin, models.Model):
for key, value in self.config.items():
# Use a copy here not to change the origin value in the config field.
result[key] = value.copy()
result[key]['uuid'] = key
element = elements.get(value['name'])
result[key]["uuid"] = key
element = elements.get(value["name"])
if element is None:
result[key]['error'] = 'Projector element does not exist.'
result[key]["error"] = "Projector element does not exist."
else:
try:
result[key].update(element.check_and_update_data(
projector_object=self,
config_entry=value))
result[key].update(
element.check_and_update_data(
projector_object=self, config_entry=value
)
)
except ProjectorException as e:
result[key]['error'] = str(e)
result[key]["error"] = str(e)
return result
@classmethod
@ -173,14 +173,14 @@ class ProjectionDefault(RESTModelMixin, models.Model):
special name like 'list_of_speakers'. The display_name is the shown
name on the front end for the user.
"""
name = models.CharField(max_length=256)
display_name = models.CharField(max_length=256)
projector = models.ForeignKey(
Projector,
on_delete=models.CASCADE,
related_name='projectiondefaults')
Projector, on_delete=models.CASCADE, related_name="projectiondefaults"
)
def get_root_rest_element(self):
return self.projector
@ -197,17 +197,15 @@ class Tag(RESTModelMixin, models.Model):
Model for tags. This tags can be used for other models like agenda items,
motions or assignments.
"""
access_permissions = TagAccessPermissions()
name = models.CharField(
max_length=255,
unique=True)
name = models.CharField(max_length=255, unique=True)
class Meta:
ordering = ('name',)
ordering = ("name",)
default_permissions = ()
permissions = (
('can_manage_tags', 'Can manage tags'),)
permissions = (("can_manage_tags", "Can manage tags"),)
def __str__(self):
return self.name
@ -217,6 +215,7 @@ class ConfigStore(RESTModelMixin, models.Model):
"""
A model class to store all config variables in the database.
"""
access_permissions = ConfigAccessPermissions()
key = models.CharField(max_length=255, unique=True, db_index=True)
@ -228,12 +227,13 @@ class ConfigStore(RESTModelMixin, models.Model):
class Meta:
default_permissions = ()
permissions = (
('can_manage_config', 'Can manage configuration'),
('can_manage_logos_and_fonts', 'Can manage logos and fonts'))
("can_manage_config", "Can manage configuration"),
("can_manage_logos_and_fonts", "Can manage logos and fonts"),
)
@classmethod
def get_collection_string(cls):
return 'core/config'
return "core/config"
class ChatMessage(RESTModelMixin, models.Model):
@ -242,31 +242,32 @@ class ChatMessage(RESTModelMixin, models.Model):
At the moment we only have one global chat room for managers.
"""
access_permissions = ChatMessageAccessPermissions()
can_see_permission = 'core.can_use_chat'
can_see_permission = "core.can_use_chat"
message = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class Meta:
default_permissions = ()
permissions = (
('can_use_chat', 'Can use the chat'),
('can_manage_chat', 'Can manage the chat'),)
("can_use_chat", "Can use the chat"),
("can_manage_chat", "Can manage the chat"),
)
def __str__(self):
return 'Message {}'.format(self.timestamp)
return "Message {}".format(self.timestamp)
class ProjectorMessage(RESTModelMixin, models.Model):
"""
Model for ProjectorMessages.
"""
access_permissions = ProjectorMessageAccessPermissions()
message = models.TextField(blank=True)
@ -280,16 +281,18 @@ class ProjectorMessage(RESTModelMixin, models.Model):
projector message projector element is disabled.
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='core/projector-message',
id=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore
skip_autoupdate=skip_autoupdate, name="core/projector-message", id=self.pk
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
class Countdown(RESTModelMixin, models.Model):
"""
Model for countdowns.
"""
access_permissions = CountdownAccessPermissions()
description = models.CharField(max_length=256, blank=True)
@ -309,19 +312,22 @@ class Countdown(RESTModelMixin, models.Model):
countdown projector element is disabled.
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='core/countdown',
id=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore
skip_autoupdate=skip_autoupdate, name="core/countdown", id=self.pk
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
def control(self, action, skip_autoupdate=False):
if action not in ('start', 'stop', 'reset'):
raise ValueError("Action must be 'start', 'stop' or 'reset', not {}.".format(action))
if action not in ("start", "stop", "reset"):
raise ValueError(
"Action must be 'start', 'stop' or 'reset', not {}.".format(action)
)
if action == 'start':
if action == "start":
self.running = True
self.countdown_time = now().timestamp() + self.default_time
elif action == 'stop' and self.running:
elif action == "stop" and self.running:
self.running = False
self.countdown_time = self.countdown_time - now().timestamp()
else: # reset
@ -337,6 +343,7 @@ class HistoryData(models.Model):
This is not a RESTModel. It is not cachable and can only be reached by a
special viewset.
"""
full_data = JSONField()
class Meta:
@ -347,6 +354,7 @@ class HistoryManager(models.Manager):
"""
Customized model manager for the history model.
"""
def add_elements(self, elements):
"""
Method to add elements to the history. This does not trigger autoupdate.
@ -354,18 +362,26 @@ class HistoryManager(models.Manager):
with transaction.atomic():
instances = []
for element in elements:
if element['disable_history'] or element['collection_string'] == self.model.get_collection_string():
if (
element["disable_history"]
or element["collection_string"]
== self.model.get_collection_string()
):
# Do not update history for history elements itself or if history is disabled.
continue
# HistoryData is not a root rest element so there is no autoupdate and not history saving here.
data = HistoryData.objects.create(full_data=element['full_data'])
data = HistoryData.objects.create(full_data=element["full_data"])
instance = self.model(
element_id=get_element_id(element['collection_string'], element['id']),
information=element['information'],
user_id=element['user_id'],
element_id=get_element_id(
element["collection_string"], element["id"]
),
information=element["information"],
user_id=element["user_id"],
full_data=data,
)
instance.save(skip_autoupdate=True) # Skip autoupdate and of course history saving.
instance.save(
skip_autoupdate=True
) # Skip autoupdate and of course history saving.
instances.append(instance)
return instances
@ -380,14 +396,16 @@ class HistoryManager(models.Manager):
all_full_data = async_to_sync(element_cache.get_all_full_data)()
for collection_string, data in all_full_data.items():
for full_data in data:
elements.append(Element(
id=full_data['id'],
collection_string=collection_string,
full_data=full_data,
information='',
user_id=None,
disable_history=False,
))
elements.append(
Element(
id=full_data["id"],
collection_string=collection_string,
full_data=full_data,
information="",
user_id=None,
disable_history=False,
)
)
instances = self.add_elements(elements)
return instances
@ -399,29 +417,22 @@ class History(RESTModelMixin, models.Model):
This model itself is not part of the history. This means that if you
delete a user you may lose the information of the user field here.
"""
access_permissions = HistoryAccessPermissions()
objects = HistoryManager()
element_id = models.CharField(
max_length=255,
)
element_id = models.CharField(max_length=255)
now = models.DateTimeField(auto_now_add=True)
information = models.CharField(
max_length=255,
)
information = models.CharField(max_length=255)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL)
full_data = models.OneToOneField(
HistoryData,
on_delete=models.CASCADE,
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
)
full_data = models.OneToOneField(HistoryData, on_delete=models.CASCADE)
class Meta:
default_permissions = ()

View File

@ -9,29 +9,32 @@ class Clock(ProjectorElement):
"""
Clock on the projector.
"""
name = 'core/clock'
name = "core/clock"
class CountdownElement(ProjectorElement):
"""
Countdown slide for the projector.
"""
name = 'core/countdown'
name = "core/countdown"
def check_data(self):
if not Countdown.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('Countdown does not exists.')
if not Countdown.objects.filter(pk=self.config_entry.get("id")).exists():
raise ProjectorException("Countdown does not exists.")
class ProjectorMessageElement(ProjectorElement):
"""
Short message on the projector. Rendered as overlay.
"""
name = 'core/projector-message'
name = "core/projector-message"
def check_data(self):
if not ProjectorMessage.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('Message does not exists.')
if not ProjectorMessage.objects.filter(pk=self.config_entry.get("id")).exists():
raise ProjectorException("Message does not exists.")
def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]:

View File

@ -17,18 +17,21 @@ class JSONSerializerField(Field):
"""
Serializer for projector's and config JSONField.
"""
def to_internal_value(self, data):
"""
Checks that data is a dictionary. The key is a hex UUID and the
value is a dictionary with must have a key 'name'.
"""
if type(data) is not dict:
raise ValidationError({'detail': 'Data must be a dictionary.'})
raise ValidationError({"detail": "Data must be a dictionary."})
for element in data.values():
if type(element) is not dict:
raise ValidationError({'detail': 'Data must be a dictionary.'})
elif element.get('name') is None:
raise ValidationError({'detail': "Every dictionary must have a key 'name'."})
raise ValidationError({"detail": "Data must be a dictionary."})
elif element.get("name") is None:
raise ValidationError(
{"detail": "Every dictionary must have a key 'name'."}
)
return data
def to_representation(self, value):
@ -42,65 +45,82 @@ class ProjectionDefaultSerializer(ModelSerializer):
"""
Serializer for core.models.ProjectionDefault objects.
"""
class Meta:
model = ProjectionDefault
fields = ('id', 'name', 'display_name', 'projector', )
fields = ("id", "name", "display_name", "projector")
class ProjectorSerializer(ModelSerializer):
"""
Serializer for core.models.Projector objects.
"""
config = JSONSerializerField(write_only=True)
projectiondefaults = ProjectionDefaultSerializer(many=True, read_only=True)
class Meta:
model = Projector
fields = ('id', 'config', 'elements', 'scale', 'scroll', 'name', 'blank', 'width', 'height', 'projectiondefaults', )
read_only_fields = ('scale', 'scroll', 'blank', 'width', 'height', )
fields = (
"id",
"config",
"elements",
"scale",
"scroll",
"name",
"blank",
"width",
"height",
"projectiondefaults",
)
read_only_fields = ("scale", "scroll", "blank", "width", "height")
class TagSerializer(ModelSerializer):
"""
Serializer for core.models.Tag objects.
"""
class Meta:
model = Tag
fields = ('id', 'name', )
fields = ("id", "name")
class ConfigSerializer(ModelSerializer):
"""
Serializer for core.models.Tag objects.
"""
value = JSONSerializerField()
class Meta:
model = ConfigStore
fields = ('id', 'key', 'value')
fields = ("id", "key", "value")
class ChatMessageSerializer(ModelSerializer):
"""
Serializer for core.models.ChatMessage objects.
"""
class Meta:
model = ChatMessage
fields = ('id', 'message', 'timestamp', 'user', )
read_only_fields = ('user', )
fields = ("id", "message", "timestamp", "user")
read_only_fields = ("user",)
class ProjectorMessageSerializer(ModelSerializer):
"""
Serializer for core.models.ProjectorMessage objects.
"""
class Meta:
model = ProjectorMessage
fields = ('id', 'message', )
fields = ("id", "message")
def validate(self, data):
if 'message' in data:
data['message'] = validate_html(data['message'])
if "message" in data:
data["message"] = validate_html(data["message"])
return data
@ -108,9 +128,10 @@ class CountdownSerializer(ModelSerializer):
"""
Serializer for core.models.Countdown objects.
"""
class Meta:
model = Countdown
fields = ('id', 'description', 'default_time', 'countdown_time', 'running', )
fields = ("id", "description", "default_time", "countdown_time", "running")
class HistorySerializer(ModelSerializer):
@ -119,6 +140,7 @@ class HistorySerializer(ModelSerializer):
Does not contain full data of history object.
"""
class Meta:
model = History
fields = ('id', 'element_id', 'now', 'information', 'user', )
fields = ("id", "element_id", "now", "information", "user")

View File

@ -21,9 +21,8 @@ def delete_django_app_permissions(sender, **kwargs):
for auth, contenttypes and sessions.
"""
contenttypes = ContentType.objects.filter(
Q(app_label='auth') |
Q(app_label='contenttypes') |
Q(app_label='sessions'))
Q(app_label="auth") | Q(app_label="contenttypes") | Q(app_label="sessions")
)
Permission.objects.filter(content_type__in=contenttypes).delete()
@ -31,13 +30,13 @@ def get_permission_change_data(sender, permissions, **kwargs):
"""
Yields all necessary Cachables if the respective permissions change.
"""
core_app = apps.get_app_config(app_label='core')
core_app = apps.get_app_config(app_label="core")
for permission in permissions:
if permission.content_type.app_label == core_app.label:
if permission.codename == 'can_see_projector':
yield core_app.get_model('Projector')
elif permission.codename == 'can_manage_projector':
yield core_app.get_model('ProjectorMessage')
yield core_app.get_model('Countdown')
elif permission.codename == 'can_use_chat':
yield core_app.get_model('ChatMessage')
if permission.codename == "can_see_projector":
yield core_app.get_model("Projector")
elif permission.codename == "can_manage_projector":
yield core_app.get_model("ProjectorMessage")
yield core_app.get_model("Countdown")
elif permission.codename == "can_use_chat":
yield core_app.get_model("ChatMessage")

View File

@ -4,15 +4,7 @@ from . import views
urlpatterns = [
url(r'^servertime/$',
views.ServerTime.as_view(),
name='core_servertime'),
url(r'^version/$',
views.VersionView.as_view(),
name='core_version'),
url(r'^history/$',
views.HistoryView.as_view(),
name='core_history'),
url(r"^servertime/$", views.ServerTime.as_view(), name="core_servertime"),
url(r"^version/$", views.VersionView.as_view(), name="core_version"),
url(r"^history/$", views.HistoryView.as_view(), name="core_history"),
]

View File

@ -17,12 +17,7 @@ from mypy_extensions import TypedDict
from .. import __license__ as license, __url__ as url, __version__ as version
from ..utils import views as utils_views
from ..utils.arguments import arguments
from ..utils.auth import (
GROUP_ADMIN_PK,
anonymous_is_enabled,
has_perm,
in_some_groups,
)
from ..utils.auth import GROUP_ADMIN_PK, anonymous_is_enabled, has_perm, in_some_groups
from ..utils.autoupdate import inform_changed_data, inform_deleted_data
from ..utils.plugins import (
get_plugin_description,
@ -67,6 +62,7 @@ from .models import (
# Special Django views
class IndexView(View):
"""
The primary view for the OpenSlides client. Serves static files. If a file
@ -83,11 +79,11 @@ class IndexView(View):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
no_caching = arguments.get('no_template_caching', False)
if 'index' not in self.cache or no_caching:
self.cache['index'] = finders.find('index.html')
no_caching = arguments.get("no_template_caching", False)
if "index" not in self.cache or no_caching:
self.cache["index"] = finders.find("index.html")
self.index_document_root, self.index_path = os.path.split(self.cache['index'])
self.index_document_root, self.index_path = os.path.split(self.cache["index"])
def get(self, request, path, **kwargs) -> HttpResponse:
"""
@ -97,18 +93,25 @@ class IndexView(View):
try:
response = serve(request, path, **kwargs)
except Http404:
response = static.serve(request, self.index_path, document_root=self.index_document_root, **kwargs)
response = static.serve(
request,
self.index_path,
document_root=self.index_document_root,
**kwargs,
)
return response
# Viewsets for the REST API
class ProjectorViewSet(ModelViewSet):
"""
API endpoint for the projector slide info.
There are the following views: See strings in check_view_permissions().
"""
access_permissions = ProjectorAccessPermissions()
queryset = Projector.objects.all()
@ -116,18 +119,31 @@ class ProjectorViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
result = has_perm(self.request.user, 'core.can_see_projector')
elif self.action == "metadata":
result = has_perm(self.request.user, "core.can_see_projector")
elif self.action in (
'create', 'update', 'partial_update', 'destroy',
'activate_elements', 'prune_elements', 'update_elements', 'deactivate_elements', 'clear_elements',
'project', 'control_view', 'set_resolution', 'set_scroll', 'control_blank',
'broadcast', 'set_projectiondefault',
"create",
"update",
"partial_update",
"destroy",
"activate_elements",
"prune_elements",
"update_elements",
"deactivate_elements",
"clear_elements",
"project",
"control_view",
"set_resolution",
"set_scroll",
"control_blank",
"broadcast",
"set_projectiondefault",
):
result = (has_perm(self.request.user, 'core.can_see_projector') and
has_perm(self.request.user, 'core.can_manage_projector'))
result = has_perm(self.request.user, "core.can_see_projector") and has_perm(
self.request.user, "core.can_manage_projector"
)
else:
result = False
return result
@ -144,11 +160,11 @@ class ProjectorViewSet(ModelViewSet):
if projection_default.projector.id == projector_instance.id:
projection_default.projector_id = 1
projection_default.save()
if config['projector_broadcast'] == projector_instance.pk:
config['projector_broadcast'] = 0
if config["projector_broadcast"] == projector_instance.pk:
config["projector_broadcast"] = 0
return super(ProjectorViewSet, self).destroy(*args, **kwargs)
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def activate_elements(self, request, pk):
"""
REST API operation to activate projector elements. It expects a POST
@ -156,21 +172,25 @@ class ProjectorViewSet(ModelViewSet):
of dictionaries to be appended to the projector config entry.
"""
if not isinstance(request.data, list):
raise ValidationError({'detail': 'Data must be a list.'})
raise ValidationError({"detail": "Data must be a list."})
projector_instance = self.get_object()
projector_config = projector_instance.config
for element in request.data:
if element.get('name') is None:
raise ValidationError({'detail': 'Invalid projector element. Name is missing.'})
if element.get("name") is None:
raise ValidationError(
{"detail": "Invalid projector element. Name is missing."}
)
projector_config[uuid.uuid4().hex] = element
serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False)
serializer = self.get_serializer(
projector_instance, data={"config": projector_config}, partial=False
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def prune_elements(self, request, pk):
"""
REST API operation to activate projector elements. It expects a POST
@ -179,17 +199,19 @@ class ProjectorViewSet(ModelViewSet):
entries are deleted but not entries with stable == True.
"""
if not isinstance(request.data, list):
raise ValidationError({'detail': 'Data must be a list.'})
raise ValidationError({"detail": "Data must be a list."})
projector = self.get_object()
elements = request.data
if not isinstance(elements, list):
raise ValidationError({'detail': _('The data has to be a list.')})
raise ValidationError({"detail": _("The data has to be a list.")})
for element in elements:
if not isinstance(element, dict):
raise ValidationError({'detail': _('All elements have to be dicts.')})
if element.get('name') is None:
raise ValidationError({'detail': 'Invalid projector element. Name is missing.'})
raise ValidationError({"detail": _("All elements have to be dicts.")})
if element.get("name") is None:
raise ValidationError(
{"detail": "Invalid projector element. Name is missing."}
)
return Response(self.prune(projector, elements))
def prune(self, projector, elements):
@ -201,21 +223,23 @@ class ProjectorViewSet(ModelViewSet):
"""
projector_config = {}
for key, value in projector.config.items():
if value.get('stable'):
if value.get("stable"):
projector_config[key] = value
for element in elements:
projector_config[uuid.uuid4().hex] = element
serializer = self.get_serializer(projector, data={'config': projector_config}, partial=False)
serializer = self.get_serializer(
projector, data={"config": projector_config}, partial=False
)
serializer.is_valid(raise_exception=True)
serializer.save()
# reset scroll level
if (projector.scroll != 0):
if projector.scroll != 0:
projector.scroll = 0
projector.save()
return serializer.data
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def update_elements(self, request, pk):
"""
REST API operation to update projector elements. It expects a POST
@ -237,8 +261,10 @@ class ProjectorViewSet(ModelViewSet):
}
"""
if not isinstance(request.data, dict):
raise ValidationError({'detail': 'Data must be a dictionary.'})
error = {'detail': 'Data must be a dictionary with UUIDs as keys and dictionaries as values.'}
raise ValidationError({"detail": "Data must be a dictionary."})
error = {
"detail": "Data must be a dictionary with UUIDs as keys and dictionaries as values."
}
for key, value in request.data.items():
try:
uuid.UUID(hex=str(key))
@ -251,15 +277,19 @@ class ProjectorViewSet(ModelViewSet):
projector_config = projector_instance.config
for key, value in request.data.items():
if key not in projector_config:
raise ValidationError({'detail': 'Invalid projector element. Wrong UUID.'})
raise ValidationError(
{"detail": "Invalid projector element. Wrong UUID."}
)
projector_config[key].update(request.data[key])
serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False)
serializer = self.get_serializer(
projector_instance, data={"config": projector_config}, partial=False
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def deactivate_elements(self, request, pk):
"""
REST API operation to deactivate projector elements. It expects a
@ -268,12 +298,12 @@ class ProjectorViewSet(ModelViewSet):
that should be deleted.
"""
if not isinstance(request.data, list):
raise ValidationError({'detail': 'Data must be a list of hex UUIDs.'})
raise ValidationError({"detail": "Data must be a list of hex UUIDs."})
for item in request.data:
try:
uuid.UUID(hex=str(item))
except ValueError:
raise ValidationError({'detail': 'Data must be a list of hex UUIDs.'})
raise ValidationError({"detail": "Data must be a list of hex UUIDs."})
projector_instance = self.get_object()
projector_config = projector_instance.config
@ -281,14 +311,16 @@ class ProjectorViewSet(ModelViewSet):
try:
del projector_config[key]
except KeyError:
raise ValidationError({'detail': 'Invalid UUID.'})
raise ValidationError({"detail": "Invalid UUID."})
serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False)
serializer = self.get_serializer(
projector_instance, data={"config": projector_config}, partial=False
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def clear_elements(self, request, pk):
"""
REST API operation to deactivate all projector elements but not
@ -301,15 +333,17 @@ class ProjectorViewSet(ModelViewSet):
def clear(self, projector):
projector_config = {}
for key, value in projector.config.items():
if value.get('stable'):
if value.get("stable"):
projector_config[key] = value
serializer = self.get_serializer(projector, data={'config': projector_config}, partial=False)
serializer = self.get_serializer(
projector, data={"config": projector_config}, partial=False
)
serializer.is_valid(raise_exception=True)
serializer.save()
return serializer.data
@list_route(methods=['post'])
@list_route(methods=["post"])
def project(self, request, *args, **kwargs):
"""
REST API operation. Does a combination of clear_elements and prune_elements:
@ -327,33 +361,46 @@ class ProjectorViewSet(ModelViewSet):
"""
# The data has to be a dict.
if not isinstance(request.data, dict):
raise ValidationError({'detail': _('The data has to be a dict.')})
raise ValidationError({"detail": _("The data has to be a dict.")})
# Get projector ids to clear
clear_projector_ids = request.data.get('clear_ids', [])
clear_projector_ids = request.data.get("clear_ids", [])
for id in clear_projector_ids:
if not isinstance(id, int):
raise ValidationError({'detail': _('The id "{}" has to be int.').format(id)})
raise ValidationError(
{"detail": _('The id "{}" has to be int.').format(id)}
)
# Get the projector id and validate element to prune. This is optional.
prune = request.data.get('prune')
prune = request.data.get("prune")
if prune is not None:
if not isinstance(prune, dict):
raise ValidationError({'detail': _('Prune has to be an object.')})
prune_projector_id = prune.get('id')
raise ValidationError({"detail": _("Prune has to be an object.")})
prune_projector_id = prune.get("id")
if not isinstance(prune_projector_id, int):
raise ValidationError({'detail': _('The prune projector id has to be int.')})
raise ValidationError(
{"detail": _("The prune projector id has to be int.")}
)
# Get the projector after all clear operations, but check, if it exist.
if not Projector.objects.filter(pk=prune_projector_id).exists():
raise ValidationError({
'detail': _('The projector with id "{}" does not exist').format(prune_projector_id)})
raise ValidationError(
{
"detail": _('The projector with id "{}" does not exist').format(
prune_projector_id
)
}
)
prune_element = prune.get('element', {})
prune_element = prune.get("element", {})
if not isinstance(prune_element, dict):
raise ValidationError({'detail': _('Prune element has to be a dict or not given.')})
if prune_element.get('name') is None:
raise ValidationError({'detail': 'Invalid projector element. Name is missing.'})
raise ValidationError(
{"detail": _("Prune element has to be a dict or not given.")}
)
if prune_element.get("name") is None:
raise ValidationError(
{"detail": "Invalid projector element. Name is missing."}
)
# First step: Clear all given projectors
for projector in Projector.objects.filter(pk__in=clear_projector_ids):
@ -367,7 +414,7 @@ class ProjectorViewSet(ModelViewSet):
return Response()
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def set_resolution(self, request, pk):
"""
REST API operation to set the resolution.
@ -388,26 +435,34 @@ class ProjectorViewSet(ModelViewSet):
}
"""
if not isinstance(request.data, dict):
raise ValidationError({'detail': 'Data must be a dictionary.'})
if request.data.get('width') is None or request.data.get('height') is None:
raise ValidationError({'detail': 'A width and a height have to be given.'})
if not isinstance(request.data['width'], int) or not isinstance(request.data['height'], int):
raise ValidationError({'detail': 'Data has to be integers.'})
if (request.data['width'] < 800 or request.data['width'] > 3840 or
request.data['height'] < 340 or request.data['height'] > 2880):
raise ValidationError({'detail': 'The Resolution have to be between 800x340 and 3840x2880.'})
raise ValidationError({"detail": "Data must be a dictionary."})
if request.data.get("width") is None or request.data.get("height") is None:
raise ValidationError({"detail": "A width and a height have to be given."})
if not isinstance(request.data["width"], int) or not isinstance(
request.data["height"], int
):
raise ValidationError({"detail": "Data has to be integers."})
if (
request.data["width"] < 800
or request.data["width"] > 3840
or request.data["height"] < 340
or request.data["height"] > 2880
):
raise ValidationError(
{"detail": "The Resolution have to be between 800x340 and 3840x2880."}
)
projector_instance = self.get_object()
projector_instance.width = request.data['width']
projector_instance.height = request.data['height']
projector_instance.width = request.data["width"]
projector_instance.height = request.data["height"]
projector_instance.save()
message = 'Changing resolution to {width}x{height} was successful.'.format(
width=request.data['width'],
height=request.data['height'])
return Response({'detail': message})
message = "Changing resolution to {width}x{height} was successful.".format(
width=request.data["width"], height=request.data["height"]
)
return Response({"detail": message})
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def control_view(self, request, pk):
"""
REST API operation to control the projector view, i. e. scale and
@ -426,27 +481,32 @@ class ProjectorViewSet(ModelViewSet):
}
"""
if not isinstance(request.data, dict):
raise ValidationError({'detail': 'Data must be a dictionary.'})
if (request.data.get('action') not in ('scale', 'scroll') or
request.data.get('direction') not in ('up', 'down', 'reset')):
raise ValidationError({'detail': "Data must be a dictionary with an action ('scale' or 'scroll') "
"and a direction ('up', 'down' or 'reset')."})
raise ValidationError({"detail": "Data must be a dictionary."})
if request.data.get("action") not in ("scale", "scroll") or request.data.get(
"direction"
) not in ("up", "down", "reset"):
raise ValidationError(
{
"detail": "Data must be a dictionary with an action ('scale' or 'scroll') "
"and a direction ('up', 'down' or 'reset')."
}
)
projector_instance = self.get_object()
if request.data['action'] == 'scale':
if request.data['direction'] == 'up':
projector_instance.scale = F('scale') + 1
elif request.data['direction'] == 'down':
projector_instance.scale = F('scale') - 1
if request.data["action"] == "scale":
if request.data["direction"] == "up":
projector_instance.scale = F("scale") + 1
elif request.data["direction"] == "down":
projector_instance.scale = F("scale") - 1
else:
# request.data['direction'] == 'reset'
projector_instance.scale = 0
else:
# request.data['action'] == 'scroll'
if request.data['direction'] == 'up':
projector_instance.scroll = F('scroll') + 1
elif request.data['direction'] == 'down':
projector_instance.scroll = F('scroll') - 1
if request.data["direction"] == "up":
projector_instance.scroll = F("scroll") + 1
elif request.data["direction"] == "down":
projector_instance.scroll = F("scroll") - 1
else:
# request.data['direction'] == 'reset'
projector_instance.scroll = 0
@ -454,12 +514,13 @@ class ProjectorViewSet(ModelViewSet):
projector_instance.save(skip_autoupdate=True)
projector_instance.refresh_from_db()
inform_changed_data(projector_instance)
message = '{action} {direction} was successful.'.format(
action=request.data['action'].capitalize(),
direction=request.data['direction'])
return Response({'detail': message})
message = "{action} {direction} was successful.".format(
action=request.data["action"].capitalize(),
direction=request.data["direction"],
)
return Response({"detail": message})
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def set_scroll(self, request, pk):
"""
REST API operation to scroll the projector.
@ -468,17 +529,18 @@ class ProjectorViewSet(ModelViewSet):
/rest/core/projector/<pk>/set_scroll/ with a new value for scroll.
"""
if not isinstance(request.data, int):
raise ValidationError({'detail': 'Data must be an int.'})
raise ValidationError({"detail": "Data must be an int."})
projector_instance = self.get_object()
projector_instance.scroll = request.data
projector_instance.save()
message = 'Setting scroll to {scroll} was successful.'.format(
scroll=request.data)
return Response({'detail': message})
message = "Setting scroll to {scroll} was successful.".format(
scroll=request.data
)
return Response({"detail": message})
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def control_blank(self, request, pk):
"""
REST API operation to blank the projector.
@ -487,16 +549,17 @@ class ProjectorViewSet(ModelViewSet):
/rest/core/projector/<pk>/control_blank/ with a value for blank.
"""
if not isinstance(request.data, bool):
raise ValidationError({'detail': 'Data must be a bool.'})
raise ValidationError({"detail": "Data must be a bool."})
projector_instance = self.get_object()
projector_instance.blank = request.data
projector_instance.save()
message = "Setting 'blank' to {blank} was successful.".format(
blank=request.data)
return Response({'detail': message})
blank=request.data
)
return Response({"detail": message})
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def broadcast(self, request, pk):
"""
REST API operation to (un-)broadcast the given projector.
@ -505,16 +568,17 @@ class ProjectorViewSet(ModelViewSet):
It expects a POST request to
/rest/core/projector/<pk>/broadcast/ without an argument
"""
if config['projector_broadcast'] == 0:
config['projector_broadcast'] = pk
if config["projector_broadcast"] == 0:
config["projector_broadcast"] = pk
message = "Setting projector {id} as broadcast projector was successful.".format(
id=pk)
id=pk
)
else:
config['projector_broadcast'] = 0
config["projector_broadcast"] = 0
message = "Disabling broadcast was successful."
return Response({'detail': message})
return Response({"detail": message})
@detail_route(methods=['post'])
@detail_route(methods=["post"])
def set_projectiondefault(self, request, pk):
"""
REST API operation to set a projectiondefault to the requested projector. The argument
@ -524,21 +588,28 @@ class ProjectorViewSet(ModelViewSet):
/rest/core/projector/<pk>/set_projectiondefault/ with the projectiondefault id as the argument
"""
if not isinstance(request.data, int):
raise ValidationError({'detail': 'Data must be an int.'})
raise ValidationError({"detail": "Data must be an int."})
try:
projectiondefault = ProjectionDefault.objects.get(pk=request.data)
except ProjectionDefault.DoesNotExist:
raise ValidationError({'detail': 'The projectiondefault with pk={pk} was not found.'.format(
pk=request.data)})
raise ValidationError(
{
"detail": "The projectiondefault with pk={pk} was not found.".format(
pk=request.data
)
}
)
else:
projector_instance = self.get_object()
projectiondefault.projector = projector_instance
projectiondefault.save()
return Response('Setting projectiondefault "{name}" to projector {projector_id} was successful.'.format(
name=projectiondefault.display_name,
projector_id=projector_instance.pk))
return Response(
'Setting projectiondefault "{name}" to projector {projector_id} was successful.'.format(
name=projectiondefault.display_name, projector_id=projector_instance.pk
)
)
class TagViewSet(ModelViewSet):
@ -548,6 +619,7 @@ class TagViewSet(ModelViewSet):
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = TagAccessPermissions()
queryset = Tag.objects.all()
@ -555,14 +627,14 @@ class TagViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
elif self.action == "metadata":
# Every authenticated user can see the metadata.
# Anonymous users can do so if they are enabled.
result = self.request.user.is_authenticated or anonymous_is_enabled()
elif self.action in ('create', 'partial_update', 'update', 'destroy'):
result = has_perm(self.request.user, 'core.can_manage_tags')
elif self.action in ("create", "partial_update", "update", "destroy"):
result = has_perm(self.request.user, "core.can_manage_tags")
else:
result = False
return result
@ -575,6 +647,7 @@ class ConfigViewSet(ModelViewSet):
There are the following views: metadata, list, retrieve, update and
partial_update.
"""
access_permissions = ConfigAccessPermissions()
queryset = ConfigStore.objects.all()
@ -582,22 +655,22 @@ class ConfigViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
elif self.action == "metadata":
# Every authenticated user can see the metadata and list or
# retrieve the config. Anonymous users can do so if they are
# enabled.
result = self.request.user.is_authenticated or anonymous_is_enabled()
elif self.action in ('partial_update', 'update'):
elif self.action in ("partial_update", "update"):
# The user needs 'core.can_manage_logos_and_fonts' for all config values
# starting with 'logo' and 'font'. For all other config values th euser needs
# the default permissions 'core.can_manage_config'.
pk = self.kwargs['pk']
if pk.startswith('logo') or pk.startswith('font'):
result = has_perm(self.request.user, 'core.can_manage_logos_and_fonts')
pk = self.kwargs["pk"]
if pk.startswith("logo") or pk.startswith("font"):
result = has_perm(self.request.user, "core.can_manage_logos_and_fonts")
else:
result = has_perm(self.request.user, 'core.can_manage_config')
result = has_perm(self.request.user, "core.can_manage_config")
else:
result = False
return result
@ -608,10 +681,10 @@ class ConfigViewSet(ModelViewSet):
Example: {"value": 42}
"""
key = kwargs['pk']
value = request.data.get('value')
key = kwargs["pk"]
value = request.data.get("value")
if value is None:
raise ValidationError({'detail': 'Invalid input. Config value is missing.'})
raise ValidationError({"detail": "Invalid input. Config value is missing."})
# Validate and change value.
try:
@ -619,10 +692,10 @@ class ConfigViewSet(ModelViewSet):
except ConfigNotFound:
raise Http404
except ConfigError as e:
raise ValidationError({'detail': str(e)})
raise ValidationError({"detail": str(e)})
# Return response.
return Response({'key': key, 'value': value})
return Response({"key": key, "value": value})
class ChatMessageViewSet(ModelViewSet):
@ -632,6 +705,7 @@ class ChatMessageViewSet(ModelViewSet):
There are the following views: metadata, list, retrieve and create.
The views partial_update, update and destroy are disabled.
"""
access_permissions = ChatMessageAccessPermissions()
queryset = ChatMessage.objects.all()
@ -639,18 +713,18 @@ class ChatMessageViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ('metadata', 'create'):
elif self.action in ("metadata", "create"):
# We do not want anonymous users to use the chat even the anonymous
# group has the permission core.can_use_chat.
result = (
self.request.user.is_authenticated and
has_perm(self.request.user, 'core.can_use_chat'))
elif self.action == 'clear':
result = (
has_perm(self.request.user, 'core.can_use_chat') and
has_perm(self.request.user, 'core.can_manage_chat'))
result = self.request.user.is_authenticated and has_perm(
self.request.user, "core.can_use_chat"
)
elif self.action == "clear":
result = has_perm(self.request.user, "core.can_use_chat") and has_perm(
self.request.user, "core.can_manage_chat"
)
else:
result = False
return result
@ -665,7 +739,7 @@ class ChatMessageViewSet(ModelViewSet):
# to see users may not have it but can get it now.
inform_changed_data([self.request.user])
@list_route(methods=['post'])
@list_route(methods=["post"])
def clear(self, request):
"""
Deletes all chat messages.
@ -679,7 +753,7 @@ class ChatMessageViewSet(ModelViewSet):
# Trigger autoupdate and setup response.
if len(args) > 0:
inform_deleted_data(args)
return Response({'detail': _('All chat messages deleted successfully.')})
return Response({"detail": _("All chat messages deleted successfully.")})
class ProjectorMessageViewSet(ModelViewSet):
@ -689,6 +763,7 @@ class ProjectorMessageViewSet(ModelViewSet):
There are the following views: list, retrieve, create, update,
partial_update and destroy.
"""
access_permissions = ProjectorMessageAccessPermissions()
queryset = ProjectorMessage.objects.all()
@ -696,10 +771,10 @@ class ProjectorMessageViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ('create', 'partial_update', 'update', 'destroy'):
result = has_perm(self.request.user, 'core.can_manage_projector')
elif self.action in ("create", "partial_update", "update", "destroy"):
result = has_perm(self.request.user, "core.can_manage_projector")
else:
result = False
return result
@ -712,6 +787,7 @@ class CountdownViewSet(ModelViewSet):
There are the following views: list, retrieve, create, update,
partial_update and destroy.
"""
access_permissions = CountdownAccessPermissions()
queryset = Countdown.objects.all()
@ -719,10 +795,10 @@ class CountdownViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ('create', 'partial_update', 'update', 'destroy'):
result = has_perm(self.request.user, 'core.can_manage_projector')
elif self.action in ("create", "partial_update", "update", "destroy"):
result = has_perm(self.request.user, "core.can_manage_projector")
else:
result = False
return result
@ -734,6 +810,7 @@ class HistoryViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
There are the following views: list, retrieve, clear_history.
"""
access_permissions = HistoryAccessPermissions()
queryset = History.objects.all()
@ -741,13 +818,13 @@ class HistoryViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve', 'clear_history'):
if self.action in ("list", "retrieve", "clear_history"):
result = self.get_access_permissions().check_permissions(self.request.user)
else:
result = False
return result
@list_route(methods=['post'])
@list_route(methods=["post"])
def clear_history(self, request):
"""
Deletes and rebuilds the history.
@ -769,16 +846,18 @@ class HistoryViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
inform_changed_data(history_instances)
# Setup response.
return Response({'detail': _('History was deleted successfully.')})
return Response({"detail": _("History was deleted successfully.")})
# Special API views
class ServerTime(utils_views.APIView):
"""
Returns the server time as UNIX timestamp.
"""
http_method_names = ['get']
http_method_names = ["get"]
def get_context_data(self, **context):
return now().timestamp()
@ -789,27 +868,36 @@ class VersionView(utils_views.APIView):
Returns a dictionary with the OpenSlides version and the version of all
plugins.
"""
http_method_names = ['get']
http_method_names = ["get"]
def get_context_data(self, **context):
Result = TypedDict('Result', {
'openslides_version': str,
'openslides_license': str,
'openslides_url': str,
'plugins': List[Dict[str, str]]})
Result = TypedDict(
"Result",
{
"openslides_version": str,
"openslides_license": str,
"openslides_url": str,
"plugins": List[Dict[str, str]],
},
)
result: Result = dict(
openslides_version=version,
openslides_license=license,
openslides_url=url,
plugins=[])
plugins=[],
)
# Versions of plugins.
for plugin in settings.INSTALLED_PLUGINS:
result['plugins'].append({
'verbose_name': get_plugin_verbose_name(plugin),
'description': get_plugin_description(plugin),
'version': get_plugin_version(plugin),
'license': get_plugin_license(plugin),
'url': get_plugin_url(plugin)})
result["plugins"].append(
{
"verbose_name": get_plugin_verbose_name(plugin),
"description": get_plugin_description(plugin),
"version": get_plugin_version(plugin),
"license": get_plugin_license(plugin),
"url": get_plugin_url(plugin),
}
)
return result
@ -820,7 +908,8 @@ class HistoryView(utils_views.APIView):
Use query paramter timestamp (UNIX timestamp) to get all elements from begin
until (including) this timestamp.
"""
http_method_names = ['get']
http_method_names = ["get"]
def get_context_data(self, **context):
"""
@ -830,19 +919,25 @@ class HistoryView(utils_views.APIView):
if not in_some_groups(self.request.user.pk or 0, [GROUP_ADMIN_PK]):
self.permission_denied(self.request)
try:
timestamp = int(self.request.query_params.get('timestamp', 0))
timestamp = int(self.request.query_params.get("timestamp", 0))
except (ValueError):
raise ValidationError({'detail': 'Invalid input. Timestamp should be an integer.'})
raise ValidationError(
{"detail": "Invalid input. Timestamp should be an integer."}
)
data = []
queryset = History.objects.select_related('full_data')
queryset = History.objects.select_related("full_data")
if timestamp:
queryset = queryset.filter(now__lte=datetime.datetime.fromtimestamp(timestamp))
queryset = queryset.filter(
now__lte=datetime.datetime.fromtimestamp(timestamp)
)
for instance in queryset:
data.append({
'full_data': instance.full_data.full_data,
'element_id': instance.element_id,
'timestamp': instance.now.timestamp(),
'information': instance.information,
'user_id': instance.user.pk if instance.user else None,
})
data.append(
{
"full_data": instance.full_data.full_data,
"element_id": instance.element_id,
"timestamp": instance.now.timestamp(),
"information": instance.information,
"user_id": instance.user.pk if instance.user else None,
}
)
return data

View File

@ -12,7 +12,8 @@ class NotifyWebsocketClientMessage(BaseWebsocketClientMessage):
"""
Websocket message from a client to send a message to other clients.
"""
identifier = 'notify'
identifier = "notify"
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Notify elements.",
@ -21,31 +22,24 @@ class NotifyWebsocketClientMessage(BaseWebsocketClientMessage):
"items": {
"type": "object",
"properties": {
"projectors": {
"type": "array",
"items": {"type": "integer"},
},
"reply_channels": {
"type": "array",
"items": {"type": "string"},
},
"users": {
"type": "array",
"items": {"type": "integer"},
}
}
"projectors": {"type": "array", "items": {"type": "integer"}},
"reply_channels": {"type": "array", "items": {"type": "string"}},
"users": {"type": "array", "items": {"type": "integer"}},
},
},
"minItems": 1,
}
async def receive_content(self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str) -> None:
async def receive_content(
self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str
) -> None:
await consumer.channel_layer.group_send(
"site",
{
"type": "send_notify",
"incomming": content,
"senderReplyChannelName": consumer.channel_name,
"senderUserId": consumer.scope['user']['id'],
"senderUserId": consumer.scope["user"]["id"],
},
)
@ -54,19 +48,25 @@ class ConstantsWebsocketClientMessage(BaseWebsocketClientMessage):
"""
The Client requests the constants.
"""
identifier = 'constants'
identifier = "constants"
content_required = False
async def receive_content(self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str) -> None:
async def receive_content(
self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str
) -> None:
# Return all constants to the client.
await consumer.send_json(type='constants', content=get_constants(), in_response=id)
await consumer.send_json(
type="constants", content=get_constants(), in_response=id
)
class GetElementsWebsocketClientMessage(BaseWebsocketClientMessage):
"""
The Client request database elements.
"""
identifier = 'getElements'
identifier = "getElements"
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"titel": "getElement request",
@ -74,31 +74,40 @@ class GetElementsWebsocketClientMessage(BaseWebsocketClientMessage):
"type": "object",
"properties": {
# change_id is not required
"change_id": {
"type": "integer",
}
"change_id": {"type": "integer"}
},
}
async def receive_content(self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str) -> None:
requested_change_id = content.get('change_id', 0)
async def receive_content(
self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str
) -> None:
requested_change_id = content.get("change_id", 0)
try:
element_data = await get_element_data(consumer.scope['user']['id'], requested_change_id)
element_data = await get_element_data(
consumer.scope["user"]["id"], requested_change_id
)
except ValueError as error:
await consumer.send_json(type='error', content=str(error), in_response=id)
await consumer.send_json(type="error", content=str(error), in_response=id)
else:
await consumer.send_json(type='autoupdate', content=element_data, in_response=id)
await consumer.send_json(
type="autoupdate", content=element_data, in_response=id
)
class AutoupdateWebsocketClientMessage(BaseWebsocketClientMessage):
"""
The Client turns autoupdate on or off.
"""
identifier = 'autoupdate'
async def receive_content(self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str) -> None:
identifier = "autoupdate"
async def receive_content(
self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str
) -> None:
# Turn on or off the autoupdate for the client
if content: # accept any value, that can be interpreted as bool
await consumer.channel_layer.group_add('autoupdate', consumer.channel_name)
await consumer.channel_layer.group_add("autoupdate", consumer.channel_name)
else:
await consumer.channel_layer.group_discard('autoupdate', consumer.channel_name)
await consumer.channel_layer.group_discard(
"autoupdate", consumer.channel_name
)

View File

@ -9,68 +9,70 @@ MODULE_DIR = os.path.realpath(os.path.dirname(os.path.abspath(__file__)))
# Application definition
INSTALLED_APPS = [
'openslides.core',
'openslides.users',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.staticfiles',
'rest_framework',
'channels',
'openslides.agenda',
'openslides.topics',
'openslides.motions',
'openslides.assignments',
'openslides.mediafiles',
"openslides.core",
"openslides.users",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
"rest_framework",
"channels",
"openslides.agenda",
"openslides.topics",
"openslides.motions",
"openslides.assignments",
"openslides.mediafiles",
]
INSTALLED_PLUGINS = collect_plugins() # Adds all automaticly collected plugins
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'openslides.utils.autoupdate.AutoupdateBundleMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"openslides.utils.autoupdate.AutoupdateBundleMiddleware",
]
ROOT_URLCONF = 'openslides.urls'
ROOT_URLCONF = "openslides.urls"
ALLOWED_HOSTS = ['*']
ALLOWED_HOSTS = ["*"]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
},
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
}
]
# Email
# https://docs.djangoproject.com/en/1.10/topics/email/
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_TIMEOUT = 5 # Timeout in seconds for blocking operations like the connection attempt
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_TIMEOUT = (
5
) # Timeout in seconds for blocking operations like the connection attempt
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGE_CODE = 'en'
LANGUAGE_CODE = "en"
LANGUAGES = (
('en', 'English'),
('de', 'Deutsch'),
('fr', 'Français'),
('es', 'Español'),
('pt', 'Português'),
('cs', 'Český'),
('ru', 'русский'),
("en", "English"),
("de", "Deutsch"),
("fr", "Français"),
("es", "Español"),
("pt", "Português"),
("cs", "Český"),
("ru", "русский"),
)
TIME_ZONE = 'UTC'
TIME_ZONE = "UTC"
USE_I18N = True
@ -78,62 +80,54 @@ USE_L10N = True
USE_TZ = True
LOCALE_PATHS = [
os.path.join(MODULE_DIR, 'locale'),
]
LOCALE_PATHS = [os.path.join(MODULE_DIR, "locale")]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = "/static/"
STATICFILES_DIRS = [
os.path.join(MODULE_DIR, 'static'),
]
STATICFILES_DIRS = [os.path.join(MODULE_DIR, "static")]
# Sessions and user authentication
# https://docs.djangoproject.com/en/1.10/topics/http/sessions/
# https://docs.djangoproject.com/en/1.10/topics/auth/
AUTH_USER_MODEL = 'users.User'
AUTH_USER_MODEL = "users.User"
AUTH_GROUP_MODEL = 'users.Group'
AUTH_GROUP_MODEL = "users.Group"
SESSION_COOKIE_NAME = 'OpenSlidesSessionID'
SESSION_COOKIE_NAME = "OpenSlidesSessionID"
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
CSRF_COOKIE_NAME = 'OpenSlidesCsrfToken'
CSRF_COOKIE_NAME = "OpenSlidesCsrfToken"
CSRF_COOKIE_AGE = None
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
'django.contrib.auth.hashers.BCryptPasswordHasher',
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
"django.contrib.auth.hashers.BCryptPasswordHasher",
]
# Files
# https://docs.djangoproject.com/en/1.10/topics/files/
MEDIA_URL = '/media/'
MEDIA_URL = "/media/"
# Django Channels
# http://channels.readthedocs.io/en/latest/
ASGI_APPLICATION = 'openslides.routing.application'
ASGI_APPLICATION = "openslides.routing.application"
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer',
},
}
CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
# Enable updating the last_login field for users on every login.

View File

@ -1 +1 @@
default_app_config = 'openslides.mediafiles.apps.MediafilesAppConfig'
default_app_config = "openslides.mediafiles.apps.MediafilesAppConfig"

View File

@ -8,22 +8,24 @@ class MediafileAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Mediafile and MediafileViewSet.
"""
base_permission = 'mediafiles.can_see'
base_permission = "mediafiles.can_see"
async def get_restricted_data(
self,
full_data: List[Dict[str, Any]],
user_id: int) -> List[Dict[str, Any]]:
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Returns the restricted serialized data for the instance prepared
for the user. Removes hidden mediafiles for some users.
"""
# Parse data.
if await async_has_perm(user_id, 'mediafiles.can_see') and await async_has_perm(user_id, 'mediafiles.can_see_hidden'):
if await async_has_perm(user_id, "mediafiles.can_see") and await async_has_perm(
user_id, "mediafiles.can_see_hidden"
):
data = full_data
elif await async_has_perm(user_id, 'mediafiles.can_see'):
elif await async_has_perm(user_id, "mediafiles.can_see"):
# Exclude hidden mediafiles.
data = [full for full in full_data if not full['hidden']]
data = [full for full in full_data if not full["hidden"]]
else:
data = []

View File

@ -6,8 +6,8 @@ from ..utils.projector import register_projector_elements
class MediafilesAppConfig(AppConfig):
name = 'openslides.mediafiles'
verbose_name = 'OpenSlides Mediafiles'
name = "openslides.mediafiles"
verbose_name = "OpenSlides Mediafiles"
angular_site_module = True
angular_projector_module = True
@ -27,20 +27,25 @@ class MediafilesAppConfig(AppConfig):
# Connect signals.
permission_change.connect(
get_permission_change_data,
dispatch_uid='mediafiles_get_permission_change_data')
dispatch_uid="mediafiles_get_permission_change_data",
)
# Register viewsets.
router.register(self.get_model('Mediafile').get_collection_string(), MediafileViewSet)
router.register(
self.get_model("Mediafile").get_collection_string(), MediafileViewSet
)
# register required_users
required_user.add_collection_string(self.get_model('Mediafile').get_collection_string(), required_users)
required_user.add_collection_string(
self.get_model("Mediafile").get_collection_string(), required_users
)
def get_startup_elements(self):
"""
Yields all Cachables required on startup i. e. opening the websocket
connection.
"""
yield self.get_model('Mediafile')
yield self.get_model("Mediafile")
def required_users(element: Dict[str, Any]) -> Set[int]:
@ -49,4 +54,4 @@ def required_users(element: Dict[str, Any]) -> Set[int]:
if request_user can see mediafiles. This function may return an empty
set.
"""
return set(element['uploader_id'])
return set(element["uploader_id"])

View File

@ -13,25 +13,43 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name='Mediafile',
name="Mediafile",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mediafile', models.FileField(upload_to='file')),
('title', models.CharField(max_length=255, unique=True)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('uploader', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("mediafile", models.FileField(upload_to="file")),
("title", models.CharField(max_length=255, unique=True)),
("timestamp", models.DateTimeField(auto_now_add=True)),
(
"uploader",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'permissions': (('can_see', 'Can see the list of files'), ('can_upload', 'Can upload files'), ('can_manage', 'Can manage files')),
'default_permissions': (),
'ordering': ['title'],
"permissions": (
("can_see", "Can see the list of files"),
("can_upload", "Can upload files"),
("can_manage", "Can manage files"),
),
"default_permissions": (),
"ordering": ["title"],
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
)
]

View File

@ -7,22 +7,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mediafiles', '0001_initial'),
]
dependencies = [("mediafiles", "0001_initial")]
operations = [
migrations.AlterModelOptions(
name='mediafile',
options={'default_permissions': (), 'ordering': ['title'], 'permissions': (
('can_see', 'Can see the list of files'),
('can_see_hidden', 'Can see hidden files'),
('can_upload', 'Can upload files'),
('can_manage', 'Can manage files'))},
name="mediafile",
options={
"default_permissions": (),
"ordering": ["title"],
"permissions": (
("can_see", "Can see the list of files"),
("can_see_hidden", "Can see hidden files"),
("can_upload", "Can upload files"),
("can_manage", "Can manage files"),
),
},
),
migrations.AddField(
model_name='mediafile',
name='hidden',
model_name="mediafile",
name="hidden",
field=models.BooleanField(default=False),
),
]

View File

@ -13,10 +13,11 @@ class Mediafile(RESTModelMixin, models.Model):
"""
Class for uploaded files which can be delivered under a certain url.
"""
access_permissions = MediafileAccessPermissions()
can_see_permission = 'mediafiles.can_see'
mediafile = models.FileField(upload_to='file')
access_permissions = MediafileAccessPermissions()
can_see_permission = "mediafiles.can_see"
mediafile = models.FileField(upload_to="file")
"""
See https://docs.djangoproject.com/en/dev/ref/models/fields/#filefield
for more information.
@ -26,10 +27,8 @@ class Mediafile(RESTModelMixin, models.Model):
"""A string representing the title of the file."""
uploader = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True)
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
)
"""A user the uploader of a file."""
hidden = models.BooleanField(default=False)
@ -42,13 +41,15 @@ class Mediafile(RESTModelMixin, models.Model):
"""
Meta class for the mediafile model.
"""
ordering = ['title']
ordering = ["title"]
default_permissions = ()
permissions = (
('can_see', 'Can see the list of files'),
('can_see_hidden', 'Can see hidden files'),
('can_upload', 'Can upload files'),
('can_manage', 'Can manage files'))
("can_see", "Can see the list of files"),
("can_see_hidden", "Can see hidden files"),
("can_upload", "Can upload files"),
("can_manage", "Can manage files"),
)
def __str__(self):
"""
@ -72,10 +73,11 @@ class Mediafile(RESTModelMixin, models.Model):
mediafile projector element is disabled.
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='mediafiles/mediafile',
id=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore
skip_autoupdate=skip_autoupdate, name="mediafiles/mediafile", id=self.pk
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
def get_filesize(self):
"""
@ -85,26 +87,26 @@ class Mediafile(RESTModelMixin, models.Model):
try:
size = self.mediafile.size
except OSError:
size_string = _('unknown')
size_string = _("unknown")
else:
if size < 1024:
size_string = '< 1 kB'
size_string = "< 1 kB"
elif size >= 1024 * 1024:
mB = size / 1024 / 1024
size_string = '%d MB' % mB
size_string = "%d MB" % mB
else:
kB = size / 1024
size_string = '%d kB' % kB
size_string = "%d kB" % kB
return size_string
def is_logo(self):
for key in config['logos_available']:
if config[key]['path'] == self.mediafile.url:
for key in config["logos_available"]:
if config[key]["path"] == self.mediafile.url:
return True
return False
def is_font(self):
for key in config['fonts_available']:
if config[key]['path'] == self.mediafile.url:
for key in config["fonts_available"]:
if config[key]["path"] == self.mediafile.url:
return True
return False

View File

@ -9,11 +9,12 @@ class MediafileSlide(ProjectorElement):
"""
Slide definitions for Mediafile model.
"""
name = 'mediafiles/mediafile'
name = "mediafiles/mediafile"
def check_data(self):
if not Mediafile.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('File does not exist.')
if not Mediafile.objects.filter(pk=self.config_entry.get("id")).exists():
raise ProjectorException("File does not exist.")
def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]:

View File

@ -10,9 +10,8 @@ from .models import Mediafile
class AngularCompatibleFileField(FileField):
def to_internal_value(self, data):
if data == '':
if data == "":
return None
return super(AngularCompatibleFileField, self).to_internal_value(data)
@ -20,20 +19,17 @@ class AngularCompatibleFileField(FileField):
if value is None:
return None
filetype = mimetypes.guess_type(value.path)[0]
result = {
'name': value.name,
'type': filetype
}
if filetype == 'application/pdf':
result = {"name": value.name, "type": filetype}
if filetype == "application/pdf":
try:
result['pages'] = PdfFileReader(open(value.path, 'rb')).getNumPages()
result["pages"] = PdfFileReader(open(value.path, "rb")).getNumPages()
except FileNotFoundError:
# File was deleted from server. Set 'pages' to 0.
result['pages'] = 0
result["pages"] = 0
except PdfReadError:
# File could be encrypted but not be detected by PyPDF.
result['pages'] = 0
result['encrypted'] = True
result["pages"] = 0
result["encrypted"] = True
return result
@ -41,6 +37,7 @@ class MediafileSerializer(ModelSerializer):
"""
Serializer for mediafile.models.Mediafile objects.
"""
media_url_prefix = SerializerMethodField()
filesize = SerializerMethodField()
@ -52,19 +49,20 @@ class MediafileSerializer(ModelSerializer):
super(MediafileSerializer, self).__init__(*args, **kwargs)
self.serializer_field_mapping[dbmodels.FileField] = AngularCompatibleFileField
if self.instance is not None:
self.fields['mediafile'].read_only = True
self.fields["mediafile"].read_only = True
class Meta:
model = Mediafile
fields = (
'id',
'title',
'mediafile',
'media_url_prefix',
'uploader',
'filesize',
'hidden',
'timestamp',)
"id",
"title",
"mediafile",
"media_url_prefix",
"uploader",
"filesize",
"hidden",
"timestamp",
)
def get_filesize(self, mediafile):
return mediafile.get_filesize()

View File

@ -5,8 +5,11 @@ def get_permission_change_data(sender, permissions=None, **kwargs):
"""
Yields all necessary collections if 'mediafiles.can_see' permission changes.
"""
mediafiles_app = apps.get_app_config(app_label='mediafiles')
mediafiles_app = apps.get_app_config(app_label="mediafiles")
for permission in permissions:
# There could be only one 'mediafiles.can_see' and then we want to return data.
if permission.content_type.app_label == mediafiles_app.label and permission.codename == 'can_see':
if (
permission.content_type.app_label == mediafiles_app.label
and permission.codename == "can_see"
):
yield from mediafiles_app.get_startup_elements()

View File

@ -9,6 +9,7 @@ from .models import Mediafile
# Viewsets for the REST API
class MediafileViewSet(ModelViewSet):
"""
API endpoint for mediafile objects.
@ -16,6 +17,7 @@ class MediafileViewSet(ModelViewSet):
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = MediafileAccessPermissions()
queryset = Mediafile.objects.all()
@ -23,20 +25,24 @@ class MediafileViewSet(ModelViewSet):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
result = has_perm(self.request.user, 'mediafiles.can_see')
elif self.action == 'create':
result = (has_perm(self.request.user, 'mediafiles.can_see') and
has_perm(self.request.user, 'mediafiles.can_upload'))
elif self.action in ('partial_update', 'update'):
result = (has_perm(self.request.user, 'mediafiles.can_see') and
has_perm(self.request.user, 'mediafiles.can_upload') and
has_perm(self.request.user, 'mediafiles.can_manage'))
elif self.action == 'destroy':
result = (has_perm(self.request.user, 'mediafiles.can_see') and
has_perm(self.request.user, 'mediafiles.can_manage'))
elif self.action == "metadata":
result = has_perm(self.request.user, "mediafiles.can_see")
elif self.action == "create":
result = has_perm(self.request.user, "mediafiles.can_see") and has_perm(
self.request.user, "mediafiles.can_upload"
)
elif self.action in ("partial_update", "update"):
result = (
has_perm(self.request.user, "mediafiles.can_see")
and has_perm(self.request.user, "mediafiles.can_upload")
and has_perm(self.request.user, "mediafiles.can_manage")
)
elif self.action == "destroy":
result = has_perm(self.request.user, "mediafiles.can_see") and has_perm(
self.request.user, "mediafiles.can_manage"
)
else:
result = False
return result
@ -46,13 +52,15 @@ class MediafileViewSet(ModelViewSet):
Customized view endpoint to upload a new file.
"""
# Check permission to check if the uploader has to be changed.
uploader_id = self.request.data.get('uploader_id')
if (uploader_id and
not has_perm(request.user, 'mediafiles.can_manage') and
str(self.request.user.pk) != str(uploader_id)):
uploader_id = self.request.data.get("uploader_id")
if (
uploader_id
and not has_perm(request.user, "mediafiles.can_manage")
and str(self.request.user.pk) != str(uploader_id)
):
self.permission_denied(request)
if not self.request.data.get('mediafile'):
raise ValidationError({'detail': 'You forgot to provide a file.'})
if not self.request.data.get("mediafile"):
raise ValidationError({"detail": "You forgot to provide a file."})
return super().create(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
@ -77,9 +85,11 @@ def protected_serve(request, path, document_root=None, show_indexes=False):
except Mediafile.DoesNotExist:
return HttpResponseNotFound(content="Not found.")
can_see = has_perm(request.user, 'mediafiles.can_see')
can_see = has_perm(request.user, "mediafiles.can_see")
is_special_file = mediafile.is_logo() or mediafile.is_font()
is_hidden_but_no_perms = mediafile.hidden and not has_perm(request.user, 'mediafiles.can_see_hidden')
is_hidden_but_no_perms = mediafile.hidden and not has_perm(
request.user, "mediafiles.can_see_hidden"
)
if not is_special_file and (not can_see or is_hidden_but_no_perms):
return HttpResponseForbidden(content="Forbidden.")

View File

@ -1 +1 @@
default_app_config = 'openslides.motions.apps.MotionsAppConfig'
default_app_config = "openslides.motions.apps.MotionsAppConfig"

View File

@ -9,12 +9,12 @@ class MotionAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Motion and MotionViewSet.
"""
base_permission = 'motions.can_see'
base_permission = "motions.can_see"
async def get_restricted_data(
self,
full_data: List[Dict[str, Any]],
user_id: int) -> List[Dict[str, Any]]:
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Returns the restricted serialized data for the instance prepared for
the user. Removes motion if the user has not the permission to see
@ -23,33 +23,37 @@ class MotionAccessPermissions(BaseAccessPermissions):
personal notes.
"""
# Parse data.
if await async_has_perm(user_id, 'motions.can_see'):
if await async_has_perm(user_id, "motions.can_see"):
# TODO: Refactor this after personal_notes system is refactored.
data = []
for full in full_data:
# Check if user is submitter of this motion.
if user_id:
is_submitter = user_id in [
submitter['user_id'] for submitter in full.get('submitters', [])]
submitter["user_id"] for submitter in full.get("submitters", [])
]
else:
# Anonymous users can not be submitters.
is_submitter = False
# Check see permission for this motion.
required_permission_to_see = full['state_required_permission_to_see']
required_permission_to_see = full["state_required_permission_to_see"]
permission = (
not required_permission_to_see or
await async_has_perm(user_id, required_permission_to_see) or
await async_has_perm(user_id, 'motions.can_manage') or
is_submitter)
not required_permission_to_see
or await async_has_perm(user_id, required_permission_to_see)
or await async_has_perm(user_id, "motions.can_manage")
or is_submitter
)
# Parse single motion.
if permission:
full_copy = deepcopy(full)
full_copy['comments'] = []
for comment in full['comments']:
if await async_in_some_groups(user_id, comment['read_groups_id']):
full_copy['comments'].append(comment)
full_copy["comments"] = []
for comment in full["comments"]:
if await async_in_some_groups(
user_id, comment["read_groups_id"]
):
full_copy["comments"].append(comment)
data.append(full_copy)
else:
data = []
@ -61,23 +65,23 @@ class MotionChangeRecommendationAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for MotionChangeRecommendation and MotionChangeRecommendationViewSet.
"""
base_permission = 'motions.can_see'
base_permission = "motions.can_see"
async def get_restricted_data(
self,
full_data: List[Dict[str, Any]],
user_id: int) -> List[Dict[str, Any]]:
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Removes change recommendations if they are internal and the user has
not the can_manage permission. To see change recommendation the user needs
the can_see permission.
"""
# Parse data.
if await async_has_perm(user_id, 'motions.can_see'):
has_manage_perms = await async_has_perm(user_id, 'motion.can_manage')
if await async_has_perm(user_id, "motions.can_see"):
has_manage_perms = await async_has_perm(user_id, "motion.can_manage")
data = []
for full in full_data:
if not full['internal'] or has_manage_perms:
if not full["internal"] or has_manage_perms:
data.append(full)
else:
data = []
@ -89,22 +93,22 @@ class MotionCommentSectionAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for MotionCommentSection and MotionCommentSectionViewSet.
"""
base_permission = 'motions.can_see'
base_permission = "motions.can_see"
async def get_restricted_data(
self,
full_data: List[Dict[str, Any]],
user_id: int) -> List[Dict[str, Any]]:
self, full_data: List[Dict[str, Any]], user_id: int
) -> 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 await async_has_perm(user_id, 'motions.can_manage'):
if await async_has_perm(user_id, "motions.can_manage"):
data = full_data
else:
for full in full_data:
read_groups = full.get('read_groups_id', [])
read_groups = full.get("read_groups_id", [])
if await async_in_some_groups(user_id, read_groups):
data.append(full)
return data
@ -114,25 +118,29 @@ class StatuteParagraphAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for StatuteParagraph and StatuteParagraphViewSet.
"""
base_permission = 'motions.can_see'
base_permission = "motions.can_see"
class CategoryAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Category and CategoryViewSet.
"""
base_permission = 'motions.can_see'
base_permission = "motions.can_see"
class MotionBlockAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Category and CategoryViewSet.
"""
base_permission = 'motions.can_see'
base_permission = "motions.can_see"
class WorkflowAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Workflow and WorkflowViewSet.
"""
base_permission = 'motions.can_see'
base_permission = "motions.can_see"

View File

@ -7,8 +7,8 @@ from ..utils.projector import register_projector_elements
class MotionsAppConfig(AppConfig):
name = 'openslides.motions'
verbose_name = 'OpenSlides Motion'
name = "openslides.motions"
verbose_name = "OpenSlides Motion"
angular_site_module = True
angular_projector_module = True
@ -17,10 +17,7 @@ class MotionsAppConfig(AppConfig):
from openslides.core.signals import permission_change
from openslides.utils.rest_api import router
from .projector import get_projector_elements
from .signals import (
create_builtin_workflows,
get_permission_change_data,
)
from .signals import create_builtin_workflows, get_permission_change_data
from . import serializers # noqa
from .views import (
CategoryViewSet,
@ -40,29 +37,49 @@ class MotionsAppConfig(AppConfig):
# Connect signals.
post_migrate.connect(
create_builtin_workflows,
dispatch_uid='motion_create_builtin_workflows')
create_builtin_workflows, dispatch_uid="motion_create_builtin_workflows"
)
permission_change.connect(
get_permission_change_data,
dispatch_uid='motions_get_permission_change_data')
dispatch_uid="motions_get_permission_change_data",
)
# Register viewsets.
router.register(self.get_model('Category').get_collection_string(), CategoryViewSet)
router.register(self.get_model('StatuteParagraph').get_collection_string(), StatuteParagraphViewSet)
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)
router.register(self.get_model('MotionPoll').get_collection_string(), MotionPollViewSet)
router.register(self.get_model('State').get_collection_string(), StateViewSet)
router.register(
self.get_model("Category").get_collection_string(), CategoryViewSet
)
router.register(
self.get_model("StatuteParagraph").get_collection_string(),
StatuteParagraphViewSet,
)
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,
)
router.register(
self.get_model("MotionPoll").get_collection_string(), MotionPollViewSet
)
router.register(self.get_model("State").get_collection_string(), StateViewSet)
# Register required_users
required_user.add_collection_string(self.get_model('Motion').get_collection_string(), required_users)
required_user.add_collection_string(
self.get_model("Motion").get_collection_string(), required_users
)
def get_config_variables(self):
from .config_variables import get_config_variables
return get_config_variables()
def get_startup_elements(self):
@ -70,8 +87,15 @@ class MotionsAppConfig(AppConfig):
Yields all Cachables required on startup i. e. opening the websocket
connection.
"""
for model_name in ('Category', 'StatuteParagraph', 'Motion', 'MotionBlock',
'Workflow', 'MotionChangeRecommendation', 'MotionCommentSection'):
for model_name in (
"Category",
"StatuteParagraph",
"Motion",
"MotionBlock",
"Workflow",
"MotionChangeRecommendation",
"MotionCommentSection",
):
yield self.get_model(model_name)
@ -81,6 +105,8 @@ def required_users(element: Dict[str, Any]) -> Set[int]:
any motion if request_user can see motions. This function may return an
empty set.
"""
submitters_supporters = set([submitter['user_id'] for submitter in element['submitters']])
submitters_supporters.update(element['supporters_id'])
submitters_supporters = set(
[submitter["user_id"] for submitter in element["submitters"]]
)
submitters_supporters.update(element["supporters_id"])
return submitters_supporters

View File

@ -11,8 +11,10 @@ def get_workflow_choices():
Returns a list of all workflows to be used as choices for the config variable
'motions_workflow'. Each list item contains the pk and the display name.
"""
return [{'value': str(workflow.pk), 'display_name': workflow.name}
for workflow in Workflow.objects.all()]
return [
{"value": str(workflow.pk), "display_name": workflow.name}
for workflow in Workflow.objects.all()
]
def get_config_variables():
@ -26,300 +28,339 @@ def get_config_variables():
# General
yield ConfigVariable(
name='motions_workflow',
default_value='1',
input_type='choice',
label='Workflow of new motions',
name="motions_workflow",
default_value="1",
input_type="choice",
label="Workflow of new motions",
choices=get_workflow_choices,
weight=310,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_statute_amendments_workflow',
default_value='1',
input_type='choice',
label='Workflow of new statute amendments',
name="motions_statute_amendments_workflow",
default_value="1",
input_type="choice",
label="Workflow of new statute amendments",
choices=get_workflow_choices,
weight=312,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_identifier',
default_value='per_category',
input_type='choice',
label='Identifier',
name="motions_identifier",
default_value="per_category",
input_type="choice",
label="Identifier",
choices=(
{'value': 'per_category', 'display_name': 'Numbered per category'},
{'value': 'serially_numbered', 'display_name': 'Serially numbered'},
{'value': 'manually', 'display_name': 'Set it manually'}),
{"value": "per_category", "display_name": "Numbered per category"},
{"value": "serially_numbered", "display_name": "Serially numbered"},
{"value": "manually", "display_name": "Set it manually"},
),
weight=315,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_preamble',
default_value='The assembly may decide:',
label='Motion preamble',
name="motions_preamble",
default_value="The assembly may decide:",
label="Motion preamble",
weight=320,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_default_line_numbering',
default_value='none',
input_type='choice',
label='Default line numbering',
name="motions_default_line_numbering",
default_value="none",
input_type="choice",
label="Default line numbering",
choices=(
{'value': 'outside', 'display_name': 'outside'},
{'value': 'inline', 'display_name': 'inline'},
{'value': 'none', 'display_name': 'Disabled'}),
{"value": "outside", "display_name": "outside"},
{"value": "inline", "display_name": "inline"},
{"value": "none", "display_name": "Disabled"},
),
weight=322,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_line_length',
name="motions_line_length",
default_value=90,
input_type='integer',
label='Line length',
help_text='The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40',
input_type="integer",
label="Line length",
help_text="The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40",
weight=323,
group='Motions',
subgroup='General',
validators=(MinValueValidator(40),))
group="Motions",
subgroup="General",
validators=(MinValueValidator(40),),
)
yield ConfigVariable(
name='motions_disable_reason_on_projector',
name="motions_disable_reason_on_projector",
default_value=False,
input_type='boolean',
label='Hide reason on projector',
input_type="boolean",
label="Hide reason on projector",
weight=325,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_disable_sidebox_on_projector',
name="motions_disable_sidebox_on_projector",
default_value=False,
input_type='boolean',
label='Hide meta information box on projector',
input_type="boolean",
label="Hide meta information box on projector",
weight=326,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_disable_recommendation_on_projector',
name="motions_disable_recommendation_on_projector",
default_value=False,
input_type='boolean',
label='Hide recommendation on projector',
input_type="boolean",
label="Hide recommendation on projector",
weight=327,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_stop_submitting',
name="motions_stop_submitting",
default_value=False,
input_type='boolean',
label='Stop submitting new motions by non-staff users',
input_type="boolean",
label="Stop submitting new motions by non-staff users",
weight=331,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_recommendations_by',
default_value='',
label='Name of recommender',
help_text='Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.',
name="motions_recommendations_by",
default_value="",
label="Name of recommender",
help_text="Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.",
weight=332,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_statute_recommendations_by',
default_value='',
label='Name of recommender for statute amendments',
help_text='Will be displayed as label before selected recommendation in statute amendments. ' +
'Use an empty value to disable the recommendation system for statute amendments.',
name="motions_statute_recommendations_by",
default_value="",
label="Name of recommender for statute amendments",
help_text="Will be displayed as label before selected recommendation in statute amendments. "
+ "Use an empty value to disable the recommendation system for statute amendments.",
weight=333,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
yield ConfigVariable(
name='motions_recommendation_text_mode',
default_value='original',
input_type='choice',
label='Default text version for change recommendations',
name="motions_recommendation_text_mode",
default_value="original",
input_type="choice",
label="Default text version for change recommendations",
choices=(
{'value': 'original', 'display_name': 'Original version'},
{'value': 'changed', 'display_name': 'Changed version'},
{'value': 'diff', 'display_name': 'Diff version'},
{'value': 'agreed', 'display_name': 'Final version'}),
{"value": "original", "display_name": "Original version"},
{"value": "changed", "display_name": "Changed version"},
{"value": "diff", "display_name": "Diff version"},
{"value": "agreed", "display_name": "Final version"},
),
weight=334,
group='Motions',
subgroup='General')
group="Motions",
subgroup="General",
)
# Amendments
yield ConfigVariable(
name='motions_statutes_enabled',
name="motions_statutes_enabled",
default_value=False,
input_type='boolean',
label='Activate statute amendments',
input_type="boolean",
label="Activate statute amendments",
weight=335,
group='Motions',
subgroup='Amendments')
group="Motions",
subgroup="Amendments",
)
yield ConfigVariable(
name='motions_amendments_enabled',
name="motions_amendments_enabled",
default_value=False,
input_type='boolean',
label='Activate amendments',
input_type="boolean",
label="Activate amendments",
weight=336,
group='Motions',
subgroup='Amendments')
group="Motions",
subgroup="Amendments",
)
yield ConfigVariable(
name='motions_amendments_main_table',
name="motions_amendments_main_table",
default_value=False,
input_type='boolean',
label='Show amendments together with motions',
input_type="boolean",
label="Show amendments together with motions",
weight=337,
group='Motions',
subgroup='Amendments')
group="Motions",
subgroup="Amendments",
)
yield ConfigVariable(
name='motions_amendments_prefix',
default_value='-',
label='Prefix for the identifier for amendments',
name="motions_amendments_prefix",
default_value="-",
label="Prefix for the identifier for amendments",
weight=340,
group='Motions',
subgroup='Amendments')
group="Motions",
subgroup="Amendments",
)
yield ConfigVariable(
name='motions_amendments_text_mode',
default_value='freestyle',
input_type='choice',
label='How to create new amendments',
name="motions_amendments_text_mode",
default_value="freestyle",
input_type="choice",
label="How to create new amendments",
choices=(
{'value': 'freestyle', 'display_name': 'Empty text field'},
{'value': 'fulltext', 'display_name': 'Edit the whole motion text'},
{'value': 'paragraph', 'display_name': 'Paragraph-based, Diff-enabled'},
{"value": "freestyle", "display_name": "Empty text field"},
{"value": "fulltext", "display_name": "Edit the whole motion text"},
{"value": "paragraph", "display_name": "Paragraph-based, Diff-enabled"},
),
weight=342,
group='Motions',
subgroup='Amendments')
group="Motions",
subgroup="Amendments",
)
# Supporters
yield ConfigVariable(
name='motions_min_supporters',
name="motions_min_supporters",
default_value=0,
input_type='integer',
label='Number of (minimum) required supporters for a motion',
help_text='Choose 0 to disable the supporting system.',
input_type="integer",
label="Number of (minimum) required supporters for a motion",
help_text="Choose 0 to disable the supporting system.",
weight=345,
group='Motions',
subgroup='Supporters',
validators=(MinValueValidator(0),))
group="Motions",
subgroup="Supporters",
validators=(MinValueValidator(0),),
)
yield ConfigVariable(
name='motions_remove_supporters',
name="motions_remove_supporters",
default_value=False,
input_type='boolean',
label='Remove all supporters of a motion if a submitter edits his motion in early state',
input_type="boolean",
label="Remove all supporters of a motion if a submitter edits his motion in early state",
weight=350,
group='Motions',
subgroup='Supporters')
group="Motions",
subgroup="Supporters",
)
# Voting and ballot papers
yield ConfigVariable(
name='motions_poll_100_percent_base',
default_value='YES_NO_ABSTAIN',
input_type='choice',
label='The 100 % base of a voting result consists of',
name="motions_poll_100_percent_base",
default_value="YES_NO_ABSTAIN",
input_type="choice",
label="The 100 % base of a voting result consists of",
choices=(
{'value': 'YES_NO_ABSTAIN', 'display_name': 'Yes/No/Abstain'},
{'value': 'YES_NO', 'display_name': 'Yes/No'},
{'value': 'VALID', 'display_name': 'All valid ballots'},
{'value': 'CAST', 'display_name': 'All casted ballots'},
{'value': 'DISABLED', 'display_name': 'Disabled (no percents)'}
),
{"value": "YES_NO_ABSTAIN", "display_name": "Yes/No/Abstain"},
{"value": "YES_NO", "display_name": "Yes/No"},
{"value": "VALID", "display_name": "All valid ballots"},
{"value": "CAST", "display_name": "All casted ballots"},
{"value": "DISABLED", "display_name": "Disabled (no percents)"},
),
weight=355,
group='Motions',
subgroup='Voting and ballot papers')
group="Motions",
subgroup="Voting and ballot papers",
)
# TODO: Add server side validation of the choices.
yield ConfigVariable(
name='motions_poll_default_majority_method',
default_value=majorityMethods[0]['value'],
input_type='choice',
name="motions_poll_default_majority_method",
default_value=majorityMethods[0]["value"],
input_type="choice",
choices=majorityMethods,
label='Required majority',
help_text='Default method to check whether a motion has reached the required majority.',
label="Required majority",
help_text="Default method to check whether a motion has reached the required majority.",
weight=357,
group='Motions',
subgroup='Voting and ballot papers')
group="Motions",
subgroup="Voting and ballot papers",
)
yield ConfigVariable(
name='motions_pdf_ballot_papers_selection',
default_value='CUSTOM_NUMBER',
input_type='choice',
label='Number of ballot papers (selection)',
name="motions_pdf_ballot_papers_selection",
default_value="CUSTOM_NUMBER",
input_type="choice",
label="Number of ballot papers (selection)",
choices=(
{'value': 'NUMBER_OF_DELEGATES', 'display_name': 'Number of all delegates'},
{'value': 'NUMBER_OF_ALL_PARTICIPANTS', 'display_name': 'Number of all participants'},
{'value': 'CUSTOM_NUMBER', 'display_name': 'Use the following custom number'}),
{"value": "NUMBER_OF_DELEGATES", "display_name": "Number of all delegates"},
{
"value": "NUMBER_OF_ALL_PARTICIPANTS",
"display_name": "Number of all participants",
},
{
"value": "CUSTOM_NUMBER",
"display_name": "Use the following custom number",
},
),
weight=360,
group='Motions',
subgroup='Voting and ballot papers')
group="Motions",
subgroup="Voting and ballot papers",
)
yield ConfigVariable(
name='motions_pdf_ballot_papers_number',
name="motions_pdf_ballot_papers_number",
default_value=8,
input_type='integer',
label='Custom number of ballot papers',
input_type="integer",
label="Custom number of ballot papers",
weight=365,
group='Motions',
subgroup='Voting and ballot papers',
validators=(MinValueValidator(1),))
group="Motions",
subgroup="Voting and ballot papers",
validators=(MinValueValidator(1),),
)
# PDF and DOCX export
yield ConfigVariable(
name='motions_export_title',
default_value='Motions',
label='Title for PDF and DOCX documents (all motions)',
name="motions_export_title",
default_value="Motions",
label="Title for PDF and DOCX documents (all motions)",
weight=370,
group='Motions',
subgroup='Export')
group="Motions",
subgroup="Export",
)
yield ConfigVariable(
name='motions_export_preamble',
default_value='',
label='Preamble text for PDF and DOCX documents (all motions)',
name="motions_export_preamble",
default_value="",
label="Preamble text for PDF and DOCX documents (all motions)",
weight=375,
group='Motions',
subgroup='Export')
group="Motions",
subgroup="Export",
)
yield ConfigVariable(
name='motions_export_category_sorting',
default_value='prefix',
input_type='choice',
label='Sort categories by',
name="motions_export_category_sorting",
default_value="prefix",
input_type="choice",
label="Sort categories by",
choices=(
{'value': 'prefix', 'display_name': 'Prefix'},
{'value': 'name', 'display_name': 'Name'}),
{"value": "prefix", "display_name": "Prefix"},
{"value": "name", "display_name": "Name"},
),
weight=380,
group='Motions',
subgroup='Export')
group="Motions",
subgroup="Export",
)
yield ConfigVariable(
name='motions_export_sequential_number',
name="motions_export_sequential_number",
default_value=True,
input_type='boolean',
label='Include the sequential number in PDF and DOCX',
input_type="boolean",
label="Include the sequential number in PDF and DOCX",
weight=385,
group='Motions',
subgroup='Export')
group="Motions",
subgroup="Export",
)

View File

@ -3,4 +3,5 @@ from openslides.utils.exceptions import OpenSlidesError
class WorkflowError(OpenSlidesError):
"""Exception raised when errors in a workflow or state accure."""
pass

View File

@ -15,200 +15,344 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('mediafiles', '0001_initial'),
("mediafiles", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0001_initial'),
("core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='Category',
name="Category",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('prefix', models.CharField(blank=True, max_length=32)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("prefix", models.CharField(blank=True, max_length=32)),
],
options={"ordering": ["prefix"], "default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name="Motion",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"identifier",
models.CharField(
blank=True, max_length=255, null=True, unique=True
),
),
("identifier_number", models.IntegerField(null=True)),
],
options={
'ordering': ['prefix'],
'default_permissions': (),
"verbose_name": "Motion",
"permissions": (
("can_see", "Can see motions"),
("can_create", "Can create motions"),
("can_support", "Can support motions"),
("can_manage", "Can manage motions"),
),
"ordering": ("identifier",),
"default_permissions": (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='Motion',
name="MotionLog",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('identifier', models.CharField(blank=True, max_length=255, null=True, unique=True)),
('identifier_number', models.IntegerField(null=True)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("message_list", jsonfield.fields.JSONField()),
("time", models.DateTimeField(auto_now=True)),
(
"motion",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="log_messages",
to="motions.Motion",
),
),
(
"person",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'verbose_name': 'Motion',
'permissions': (
('can_see', 'Can see motions'),
('can_create', 'Can create motions'),
('can_support', 'Can support motions'),
('can_manage', 'Can manage motions')),
'ordering': ('identifier',),
'default_permissions': (),
},
options={"ordering": ["-time"], "default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='MotionLog',
name="MotionOption",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message_list', jsonfield.fields.JSONField()),
('time', models.DateTimeField(auto_now=True)),
('motion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='log_messages', to='motions.Motion')),
('person', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
)
],
options={
'ordering': ['-time'],
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='MotionOption',
name="MotionPoll",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"votesvalid",
openslides.utils.models.MinMaxIntegerField(blank=True, null=True),
),
(
"votesinvalid",
openslides.utils.models.MinMaxIntegerField(blank=True, null=True),
),
(
"votescast",
openslides.utils.models.MinMaxIntegerField(blank=True, null=True),
),
(
"motion",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="polls",
to="motions.Motion",
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='MotionPoll',
name="MotionVersion",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('votesvalid', openslides.utils.models.MinMaxIntegerField(blank=True, null=True)),
('votesinvalid', openslides.utils.models.MinMaxIntegerField(blank=True, null=True)),
('votescast', openslides.utils.models.MinMaxIntegerField(blank=True, null=True)),
('motion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='polls', to='motions.Motion')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("version_number", models.PositiveIntegerField(default=1)),
("title", models.CharField(max_length=255)),
("text", models.TextField()),
("reason", models.TextField(blank=True, null=True)),
("creation_time", models.DateTimeField(auto_now=True)),
(
"motion",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="motions.Motion",
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='MotionVersion',
name="MotionVote",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version_number', models.PositiveIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('text', models.TextField()),
('reason', models.TextField(blank=True, null=True)),
('creation_time', models.DateTimeField(auto_now=True)),
('motion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='motions.Motion')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("weight", models.IntegerField(default=1, null=True)),
("value", models.CharField(max_length=255, null=True)),
(
"option",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="motions.MotionOption",
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='MotionVote',
name="State",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('weight', models.IntegerField(default=1, null=True)),
('value', models.CharField(max_length=255, null=True)),
('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='motions.MotionOption')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("action_word", models.CharField(max_length=255)),
("css_class", models.CharField(default="primary", max_length=255)),
(
"required_permission_to_see",
models.CharField(blank=True, max_length=255),
),
("allow_support", models.BooleanField(default=False)),
("allow_create_poll", models.BooleanField(default=False)),
("allow_submitter_edit", models.BooleanField(default=False)),
("versioning", models.BooleanField(default=False)),
("leave_old_version_active", models.BooleanField(default=False)),
("dont_set_identifier", models.BooleanField(default=False)),
("next_states", models.ManyToManyField(to="motions.State")),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='State',
name="Workflow",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('action_word', models.CharField(max_length=255)),
('css_class', models.CharField(default='primary', max_length=255)),
('required_permission_to_see', models.CharField(blank=True, max_length=255)),
('allow_support', models.BooleanField(default=False)),
('allow_create_poll', models.BooleanField(default=False)),
('allow_submitter_edit', models.BooleanField(default=False)),
('versioning', models.BooleanField(default=False)),
('leave_old_version_active', models.BooleanField(default=False)),
('dont_set_identifier', models.BooleanField(default=False)),
('next_states', models.ManyToManyField(to='motions.State')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"first_state",
models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="motions.State",
),
),
],
options={
'default_permissions': (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='Workflow',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('first_state', models.OneToOneField(
null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='motions.State')),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AddField(
model_name='state',
name='workflow',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='states', to='motions.Workflow'),
),
migrations.AddField(
model_name='motionoption',
name='poll',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='motions.MotionPoll'),
),
migrations.AddField(
model_name='motion',
name='active_version',
model_name="state",
name="workflow",
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_version', to='motions.MotionVersion'),
on_delete=django.db.models.deletion.CASCADE,
related_name="states",
to="motions.Workflow",
),
),
migrations.AddField(
model_name='motion',
name='attachments',
field=models.ManyToManyField(blank=True, to='mediafiles.Mediafile'),
),
migrations.AddField(
model_name='motion',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='motions.Category'),
),
migrations.AddField(
model_name='motion',
name='parent',
model_name="motionoption",
name="poll",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='amendments', to='motions.Motion'),
on_delete=django.db.models.deletion.CASCADE, to="motions.MotionPoll"
),
),
migrations.AddField(
model_name='motion',
name='state',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='motions.State'),
model_name="motion",
name="active_version",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="active_version",
to="motions.MotionVersion",
),
),
migrations.AddField(
model_name='motion',
name='submitters',
field=models.ManyToManyField(blank=True, related_name='motion_submitters', to=settings.AUTH_USER_MODEL),
model_name="motion",
name="attachments",
field=models.ManyToManyField(blank=True, to="mediafiles.Mediafile"),
),
migrations.AddField(
model_name='motion',
name='supporters',
field=models.ManyToManyField(blank=True, related_name='motion_supporters', to=settings.AUTH_USER_MODEL),
model_name="motion",
name="category",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="motions.Category",
),
),
migrations.AddField(
model_name='motion',
name='tags',
field=models.ManyToManyField(blank=True, to='core.Tag'),
model_name="motion",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="amendments",
to="motions.Motion",
),
),
migrations.AddField(
model_name="motion",
name="state",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="motions.State",
),
),
migrations.AddField(
model_name="motion",
name="submitters",
field=models.ManyToManyField(
blank=True,
related_name="motion_submitters",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="motion",
name="supporters",
field=models.ManyToManyField(
blank=True,
related_name="motion_supporters",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="motion",
name="tags",
field=models.ManyToManyField(blank=True, to="core.Tag"),
),
migrations.AlterUniqueTogether(
name='motionversion',
unique_together=set([('motion', 'version_number')]),
name="motionversion", unique_together=set([("motion", "version_number")])
),
]

View File

@ -16,16 +16,16 @@ def change_label_of_state(apps, schema_editor):
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
State = apps.get_model('motions', 'State')
State = apps.get_model("motions", "State")
try:
state = State.objects.get(name='commited a bill')
state = State.objects.get(name="commited a bill")
except State.DoesNotExist:
# State does not exists, there is nothing to change.
pass
else:
state.name = 'refered to committee'
state.action_word = 'Refer to committee'
state.name = "refered to committee"
state.action_word = "Refer to committee"
state.save(skip_autoupdate=True)
@ -35,17 +35,17 @@ def add_recommendation_labels(apps, schema_editor):
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
State = apps.get_model('motions', 'State')
State = apps.get_model("motions", "State")
name_label_map = {
'accepted': 'Acceptance',
'rejected': 'Rejection',
'not decided': 'No decision',
'permitted': 'Permission',
'adjourned': 'Adjournment',
'not concerned': 'No concernment',
'refered to committee': 'Referral to committee',
'rejected (not authorized)': 'Rejection (not authorized)',
"accepted": "Acceptance",
"rejected": "Rejection",
"not decided": "No decision",
"permitted": "Permission",
"adjourned": "Adjournment",
"not concerned": "No concernment",
"refered to committee": "Referral to committee",
"rejected (not authorized)": "Rejection (not authorized)",
}
for state in State.objects.all():
if name_label_map.get(state.name):
@ -57,101 +57,135 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('motions', '0001_initial'),
("motions", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='MotionBlock',
name="MotionBlock",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='MotionChangeRecommendation',
name="MotionChangeRecommendation",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rejected', models.BooleanField(default=False)),
('type', models.PositiveIntegerField(default=0)),
('line_from', models.PositiveIntegerField()),
('line_to', models.PositiveIntegerField()),
('text', models.TextField(blank=True)),
('creation_time', models.DateTimeField(auto_now=True)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('motion_version', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='change_recommendations', to='motions.MotionVersion')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("rejected", models.BooleanField(default=False)),
("type", models.PositiveIntegerField(default=0)),
("line_from", models.PositiveIntegerField()),
("line_to", models.PositiveIntegerField()),
("text", models.TextField(blank=True)),
("creation_time", models.DateTimeField(auto_now=True)),
(
"author",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"motion_version",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="change_recommendations",
to="motions.MotionVersion",
),
),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AlterModelOptions(
name='motion',
name="motion",
options={
'default_permissions': (),
'ordering': (
'identifier',
"default_permissions": (),
"ordering": ("identifier",),
"permissions": (
("can_see", "Can see motions"),
("can_create", "Can create motions"),
("can_support", "Can support motions"),
("can_see_and_manage_comments", "Can see and manage comments"),
("can_manage", "Can manage motions"),
),
'permissions': (
('can_see', 'Can see motions'),
('can_create', 'Can create motions'),
('can_support', 'Can support motions'),
('can_see_and_manage_comments', 'Can see and manage comments'),
('can_manage', 'Can manage motions')
),
'verbose_name': 'Motion',
"verbose_name": "Motion",
},
),
migrations.AddField(
model_name='motion',
name='comments',
model_name="motion",
name="comments",
field=jsonfield.fields.JSONField(null=True),
),
migrations.AddField(
model_name='motion',
name='origin',
model_name="motion",
name="origin",
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='motion',
name='recommendation',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='motions.State'),
model_name="motion",
name="recommendation",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="motions.State",
),
),
migrations.AddField(
model_name='state',
name='recommendation_label',
model_name="state",
name="recommendation_label",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='state',
name='show_recommendation_extension_field',
model_name="state",
name="show_recommendation_extension_field",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='state',
name='show_state_extension_field',
model_name="state",
name="show_state_extension_field",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='motion',
name='state',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='motions.State'),
model_name="motion",
name="state",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="motions.State",
),
),
migrations.AddField(
model_name='motion',
name='motion_block',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='motions.MotionBlock'),
),
migrations.RunPython(
change_label_of_state
),
migrations.RunPython(
add_recommendation_labels
model_name="motion",
name="motion_block",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="motions.MotionBlock",
),
),
migrations.RunPython(change_label_of_state),
migrations.RunPython(add_recommendation_labels),
]

View File

@ -12,24 +12,24 @@ def change_motions_comments(apps, schema_editor):
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
ConfigStore = apps.get_model('core', 'ConfigStore')
Motion = apps.get_model('motions', 'Motion')
ConfigStore = apps.get_model("core", "ConfigStore")
Motion = apps.get_model("motions", "Motion")
try:
config_comments_fields = ConfigStore.objects.get(key='motions_comments').value
config_comments_fields = ConfigStore.objects.get(key="motions_comments").value
except ConfigStore.DoesNotExist:
config_comments_fields = [] # The old default: An empty list.
comments_fields = {}
for index, field in enumerate(config_comments_fields):
comments_fields[index+1] = field
comments_fields[index + 1] = field
max_index = len(config_comments_fields)-1
max_index = len(config_comments_fields) - 1
try:
db_value = ConfigStore.objects.get(key='motions_comments')
db_value = ConfigStore.objects.get(key="motions_comments")
except ConfigStore.DoesNotExist:
db_value = ConfigStore(key='motions_comments')
db_value = ConfigStore(key="motions_comments")
db_value.value = comments_fields
# We cannot provide skip_autoupdate=True here, becuase this object is a fake object. It does *not*
# inherit from the RESTModelMixin, so the save() methos from base_model.py (django's default)
@ -42,19 +42,13 @@ def change_motions_comments(apps, schema_editor):
for index, comment in enumerate(motion.comments or []):
if index > max_index:
break
comments[index+1] = comment
comments[index + 1] = comment
motion.comments = comments
motion.save(skip_autoupdate=True)
class Migration(migrations.Migration):
dependencies = [
('motions', '0002_misc_features'),
]
dependencies = [("motions", "0002_misc_features")]
operations = [
migrations.RunPython(
change_motions_comments
),
]
operations = [migrations.RunPython(change_motions_comments)]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0003_motion_comments'),
]
dependencies = [("motions", "0003_motion_comments")]
operations = [
migrations.AddField(
model_name='motionchangerecommendation',
name='other_description',
model_name="motionchangerecommendation",
name="other_description",
field=models.TextField(blank=True),
),
)
]

View File

@ -11,7 +11,7 @@ def delete_old_comment_permission(apps, schema_editor):
Deletes the old 'can_see_and_manage_comments' permission which is
split up into two seperate permissions.
"""
perm = Permission.objects.filter(codename='can_see_and_manage_comments')
perm = Permission.objects.filter(codename="can_see_and_manage_comments")
if len(perm):
perm = perm.get()
@ -26,13 +26,15 @@ def delete_old_comment_permission(apps, schema_editor):
# Create new permission
perm_see = Permission.objects.create(
codename='can_see_comments',
name='Can see comments',
content_type=content_type)
codename="can_see_comments",
name="Can see comments",
content_type=content_type,
)
perm_manage = Permission.objects.create(
codename='can_manage_comments',
name='Can manage comments',
content_type=content_type)
codename="can_manage_comments",
name="Can manage comments",
content_type=content_type,
)
for group in groups:
group.permissions.add(perm_see)
@ -42,28 +44,24 @@ def delete_old_comment_permission(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('motions', '0004_motionchangerecommendation_other_description'),
]
dependencies = [("motions", "0004_motionchangerecommendation_other_description")]
operations = [
migrations.AlterModelOptions(
name='motion',
name="motion",
options={
'default_permissions': (),
'ordering': ('identifier',),
'permissions': (
('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')
"default_permissions": (),
"ordering": ("identifier",),
"permissions": (
("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"),
),
'verbose_name': 'Motion'
"verbose_name": "Motion",
},
),
migrations.RunPython(
delete_old_comment_permission
),
migrations.RunPython(delete_old_comment_permission),
]

View File

@ -11,8 +11,8 @@ import openslides.utils.models
def move_submitters_to_own_model(apps, schema_editor):
Motion = apps.get_model('motions', 'Motion')
Submitter = apps.get_model('motions', 'Submitter')
Motion = apps.get_model("motions", "Motion")
Submitter = apps.get_model("motions", "Submitter")
for motion in Motion.objects.all():
weight = 0
@ -32,41 +32,46 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('motions', '0005_auto_20180202_1318'),
("motions", "0005_auto_20180202_1318"),
]
operations = [
migrations.RenameField(
model_name='motion',
old_name='submitters',
new_name='submittersOld',
model_name="motion", old_name="submitters", new_name="submittersOld"
),
migrations.CreateModel(
name='Submitter',
name="Submitter",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('weight', models.IntegerField(null=True)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("weight", models.IntegerField(null=True)),
],
options={
'default_permissions': (),
},
options={"default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AddField(
model_name='submitter',
name='motion',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submitters', to='motions.Motion'),
model_name="submitter",
name="motion",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="submitters",
to="motions.Motion",
),
),
migrations.AddField(
model_name='submitter',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.RunPython(
move_submitters_to_own_model
),
migrations.RemoveField(
model_name='motion',
name='submittersOld',
model_name="submitter",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
migrations.RunPython(move_submitters_to_own_model),
migrations.RemoveField(model_name="motion", name="submittersOld"),
]

View File

@ -8,14 +8,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('motions', '0006_submitter_model'),
]
dependencies = [("motions", "0006_submitter_model")]
operations = [
migrations.AddField(
model_name='motionversion',
name='amendment_paragraphs',
model_name="motionversion",
name="amendment_paragraphs",
field=jsonfield.fields.JSONField(null=True),
),
)
]

View File

@ -8,38 +8,38 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0007_motionversion_amendment_data'),
]
dependencies = [("motions", "0007_motionversion_amendment_data")]
operations = [
migrations.AlterField(
model_name='workflow',
name='first_state',
model_name="workflow",
name="first_state",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='motions.State'),
related_name="+",
to="motions.State",
),
),
migrations.AlterField(
model_name='motion',
name='state',
model_name="motion",
name="state",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='motions.State'),
related_name="+",
to="motions.State",
),
),
migrations.AlterField(
model_name='state',
name='next_states',
field=models.ManyToManyField(blank=True, to='motions.State'),
model_name="state",
name="next_states",
field=models.ManyToManyField(blank=True, to="motions.State"),
),
migrations.AlterField(
model_name='state',
name='action_word',
model_name="state",
name="action_word",
field=models.CharField(blank=True, max_length=255),
),
]

View File

@ -7,14 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0008_auto_20180702_1128'),
]
dependencies = [("motions", "0008_auto_20180702_1128")]
operations = [
migrations.AddField(
model_name='motionversion',
name='modified_final_version',
model_name="motionversion",
name="modified_final_version",
field=models.TextField(blank=True, null=True),
),
)
]

View File

@ -8,49 +8,51 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0009_motionversion_modified_final_version'),
]
dependencies = [("motions", "0009_motionversion_modified_final_version")]
operations = [
migrations.AlterField(
model_name='motionpoll',
name='votescast',
model_name="motionpoll",
name="votescast",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='motionpoll',
name='votesinvalid',
model_name="motionpoll",
name="votesinvalid",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='motionpoll',
name='votesvalid',
model_name="motionpoll",
name="votesvalid",
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
migrations.AlterField(
model_name='motionvote',
name='weight',
model_name="motionvote",
name="weight",
field=models.DecimalField(
decimal_places=6,
default=Decimal('1'),
default=Decimal("1"),
max_digits=15,
null=True,
validators=[django.core.validators.MinValueValidator(Decimal('-2'))]),
validators=[django.core.validators.MinValueValidator(Decimal("-2"))],
),
),
]

View File

@ -10,7 +10,7 @@ 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')
Motion = apps.get_model("motions", "Motion")
for motion in Motion.objects.all():
motion.title = motion.active_version.title
@ -26,7 +26,7 @@ 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')
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
@ -43,89 +43,65 @@ def migrate_active_change_recommendations(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('motions', '0010_auto_20180822_1042'),
]
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=''),
model_name="motion",
name="title",
field=models.CharField(max_length=255, default=""),
),
migrations.AddField(
model_name='motion',
name='text',
field=models.TextField(default=''),
model_name="motion", name="text", field=models.TextField(default="")
),
migrations.AddField(
model_name='motion',
name='reason',
model_name="motion",
name="reason",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='motion',
name='modified_final_version',
model_name="motion",
name="modified_final_version",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='motion',
name='amendment_paragraphs',
model_name="motion",
name="amendment_paragraphs",
field=jsonfield.fields.JSONField(
dump_kwargs={
'cls': jsonfield.encoder.JSONEncoder,
'separators': (',', ':')
"cls": jsonfield.encoder.JSONEncoder,
"separators": (",", ":"),
},
load_kwargs={},
null=True),
null=True,
),
),
# Copy old motion version data
migrations.RunPython(copy_motion_version_content_to_motion),
# Change recommendations
migrations.AddField(
model_name='motionchangerecommendation',
name='motion',
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'),
related_name="change_recommendations",
to="motions.Motion",
),
),
migrations.RunPython(migrate_active_change_recommendations),
migrations.RemoveField(
model_name='motionchangerecommendation',
name='motion_version',
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',
),
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',
),
migrations.DeleteModel(name="MotionVersion"),
]

View File

@ -8,16 +8,18 @@ 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')
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')
motions_comments = ConfigStore.objects.get(key="motions_comments")
except ConfigStore.DoesNotExist:
return
comments_sections = motions_comments.value
@ -26,14 +28,14 @@ def create_comment_sections_from_config_and_move_comments_to_own_model(apps, sch
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')
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')
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())
@ -50,12 +52,12 @@ def create_comment_sections_from_config_and_move_comments_to_own_model(apps, sch
for id, section in comments_sections.items():
if section is None:
continue
if section.get('forState', False):
if section.get("forState", False):
forStateId = id
elif section.get('forRecommendation', False):
elif section.get("forRecommendation", False):
forRecommendationId = id
else:
comment_section = MotionCommentSection(name=section['name'])
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])
@ -70,7 +72,7 @@ def create_comment_sections_from_config_and_move_comments_to_own_model(apps, sch
for section_id, comment_value in motion.comments.items():
# Skip empty sections.
comment_value = comment_value.strip()
if comment_value == '':
if comment_value == "":
continue
# Special comments will be moved to separate fields.
if section_id == forStateId:
@ -83,136 +85,147 @@ def create_comment_sections_from_config_and_move_comments_to_own_model(apps, sch
comment = MotionComment(
comment=comment_value,
motion=motion,
section=old_id_mapping[section_id])
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'),
]
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(),
model_name="motion", name="text", field=models.TextField()
),
migrations.AlterField( # remove default=''
model_name='motion',
name='title',
field=models.CharField(max_length=255),
model_name="motion", name="title", field=models.CharField(max_length=255)
),
migrations.AlterField( # remove null=True
model_name='motionchangerecommendation',
name='motion',
model_name="motionchangerecommendation",
name="motion",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='change_recommendations',
to='motions.Motion'),
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',
model_name="motion",
name="recommendation_extension",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='motion',
name='state_extension',
model_name="motion",
name="state_extension",
field=models.TextField(blank=True, null=True),
),
migrations.AlterModelOptions(
name='motion',
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'},
"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',
name="MotionComment",
fields=[
('id', models.AutoField(
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID')),
('comment', models.TextField()),
verbose_name="ID",
),
),
("comment", models.TextField()),
],
options={
'default_permissions': (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model), # type: ignore
options={"default_permissions": ()},
bases=(
openslides.utils.models.RESTModelMixin, # type: ignore
models.Model,
),
),
migrations.CreateModel(
name='MotionCommentSection',
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)),
(
"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
options={"default_permissions": ()},
bases=(
openslides.utils.models.RESTModelMixin, # type: ignore
models.Model,
),
),
migrations.AddField(
model_name='motioncomment',
name='section',
model_name="motioncomment",
name="section",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='comments',
to='motions.MotionCommentSection'),
related_name="comments",
to="motions.MotionCommentSection",
),
),
migrations.AddField(
model_name='motioncomment',
name='motion',
model_name="motioncomment",
name="motion",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='motions.Motion'),
on_delete=django.db.models.deletion.CASCADE, to="motions.Motion"
),
),
migrations.AlterUniqueTogether(
name='motioncomment',
unique_together={('motion', 'section')},
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.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',
model_name="motioncomment",
name="motion",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='comments',
to='motions.Motion'),
related_name="comments",
to="motions.Motion",
),
),
]

View File

@ -8,58 +8,55 @@ import openslides.utils.models
class Migration(migrations.Migration):
dependencies = [
('motions', '0012_motion_comments'),
]
dependencies = [("motions", "0012_motion_comments")]
operations = [
migrations.AlterModelOptions(
name='motionblock',
options={
'default_permissions': (),
'verbose_name': 'Motion block'},
name="motionblock",
options={"default_permissions": (), "verbose_name": "Motion block"},
),
migrations.AddField(
model_name='motion',
name='sort_parent',
model_name="motion",
name="sort_parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='children',
to='motions.Motion'),
related_name="children",
to="motions.Motion",
),
),
migrations.AddField(
model_name='motion',
name='weight',
field=models.IntegerField(default=10000),
model_name="motion", name="weight", field=models.IntegerField(default=10000)
),
migrations.CreateModel(
name='StatuteParagraph',
name="StatuteParagraph",
fields=[
('id', models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID')),
('title', models.CharField(max_length=255)),
('text', models.TextField()),
('weight', models.IntegerField(default=10000)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
("text", models.TextField()),
("weight", models.IntegerField(default=10000)),
],
options={
'ordering': ['weight', 'title'],
'default_permissions': (),
},
options={"ordering": ["weight", "title"], "default_permissions": ()},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AddField(
model_name='motion',
name='statute_paragraph',
model_name="motion",
name="statute_paragraph",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='motions',
to='motions.StatuteParagraph'),
related_name="motions",
to="motions.StatuteParagraph",
),
),
]

View File

@ -5,14 +5,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0013_motion_sorting_and_statute'),
]
dependencies = [("motions", "0013_motion_sorting_and_statute")]
operations = [
migrations.AddField(
model_name='motionchangerecommendation',
name='internal',
model_name="motionchangerecommendation",
name="internal",
field=models.BooleanField(default=False),
),
)
]

View File

@ -5,24 +5,22 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('motions', '0014_motionchangerecommendation_internal'),
]
dependencies = [("motions", "0014_motionchangerecommendation_internal")]
operations = [
migrations.AlterModelOptions(
name='motion',
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_metadata', 'Can manage motion metadata'),
('can_manage', 'Can manage motions')
"default_permissions": (),
"ordering": ("identifier",),
"permissions": (
("can_see", "Can see motions"),
("can_create", "Can create motions"),
("can_support", "Can support motions"),
("can_manage_metadata", "Can manage motion metadata"),
("can_manage", "Can manage motions"),
),
'verbose_name': 'Motion'
"verbose_name": "Motion",
},
),
)
]

View File

@ -5,14 +5,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0015_metadata_permission'),
]
dependencies = [("motions", "0015_metadata_permission")]
operations = [
migrations.AddField(
model_name='state',
name='merge_amendment_into_final',
model_name="state",
name="merge_amendment_into_final",
field=models.SmallIntegerField(default=0),
),
)
]

View File

@ -5,13 +5,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('motions', '0016_merge_amendment_into_final'),
]
dependencies = [("motions", "0016_merge_amendment_into_final")]
operations = [
migrations.RemoveField(
model_name='state',
name='action_word',
),
]
operations = [migrations.RemoveField(model_name="state", name="action_word")]

View File

@ -40,6 +40,7 @@ class StatuteParagraph(RESTModelMixin, models.Model):
"""
Model for parts of the statute
"""
access_permissions = StatuteParagraphAccessPermissions()
title = models.CharField(max_length=255)
@ -55,7 +56,7 @@ class StatuteParagraph(RESTModelMixin, models.Model):
class Meta:
default_permissions = ()
ordering = ['weight', 'title']
ordering = ["weight", "title"]
def __str__(self):
return self.title
@ -65,25 +66,29 @@ class MotionManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all motions. In the background we
join and prefetch all related models.
"""
return (self.get_queryset()
.select_related('state')
.prefetch_related(
'state__workflow',
'comments',
'comments__section',
'comments__section__read_groups',
'agenda_items',
'log_messages',
'polls',
'attachments',
'tags',
'submitters',
'supporters'))
return (
self.get_queryset()
.select_related("state")
.prefetch_related(
"state__workflow",
"comments",
"comments__section",
"comments__section__read_groups",
"agenda_items",
"log_messages",
"polls",
"attachments",
"tags",
"submitters",
"supporters",
)
)
class Motion(RESTModelMixin, models.Model):
@ -92,8 +97,9 @@ class Motion(RESTModelMixin, models.Model):
This class is the main entry point to all other classes related to a motion.
"""
access_permissions = MotionAccessPermissions()
can_see_permission = 'motions.can_see'
can_see_permission = "motions.can_see"
objects = MotionManager()
@ -119,10 +125,11 @@ class Motion(RESTModelMixin, models.Model):
"""The reason for a motion."""
state = models.ForeignKey(
'State',
related_name='+',
"State",
related_name="+",
on_delete=models.PROTECT, # Do not let the user delete states, that are used for motions
null=True) # TODO: Check whether null=True is necessary.
null=True,
) # TODO: Check whether null=True is necessary.
"""
The related state object.
@ -135,10 +142,8 @@ class Motion(RESTModelMixin, models.Model):
"""
recommendation = models.ForeignKey(
'State',
related_name='+',
on_delete=models.SET_NULL,
null=True)
"State", related_name="+", on_delete=models.SET_NULL, null=True
)
"""
The recommendation of a person or committee for this motion.
"""
@ -148,8 +153,7 @@ class Motion(RESTModelMixin, models.Model):
A text field fo additional information about the recommendation.
"""
identifier = models.CharField(max_length=255, null=True, blank=True,
unique=True)
identifier = models.CharField(max_length=255, null=True, blank=True, unique=True)
"""
A string as human readable identifier for the motion.
"""
@ -167,29 +171,26 @@ class Motion(RESTModelMixin, models.Model):
"""
sort_parent = models.ForeignKey(
'self',
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='children')
related_name="children",
)
"""
A parent field for multi-depth sorting of motions.
"""
category = models.ForeignKey(
'Category',
on_delete=models.SET_NULL,
null=True,
blank=True)
"Category", on_delete=models.SET_NULL, null=True, blank=True
)
"""
ForeignKey to one category of motions.
"""
motion_block = models.ForeignKey(
'MotionBlock',
on_delete=models.SET_NULL,
null=True,
blank=True)
"MotionBlock", on_delete=models.SET_NULL, null=True, blank=True
)
"""
ForeignKey to one block of motions.
"""
@ -206,11 +207,12 @@ class Motion(RESTModelMixin, models.Model):
"""
parent = models.ForeignKey(
'self',
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='amendments')
related_name="amendments",
)
"""
Field for amendments to reference to the motion that should be altered.
@ -222,7 +224,8 @@ class Motion(RESTModelMixin, models.Model):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='motions')
related_name="motions",
)
"""
Field to reference to a statute paragraph if this motion is a
statute-amendment.
@ -235,26 +238,28 @@ class Motion(RESTModelMixin, models.Model):
Tags to categorise motions.
"""
supporters = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='motion_supporters', blank=True)
supporters = models.ManyToManyField(
settings.AUTH_USER_MODEL, related_name="motion_supporters", blank=True
)
"""
Users who support this motion.
"""
# 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')
agenda_items = GenericRelation(Item, related_name="motions")
class Meta:
default_permissions = ()
permissions = (
('can_see', 'Can see motions'),
('can_create', 'Can create motions'),
('can_support', 'Can support motions'),
('can_manage_metadata', 'Can manage motion metadata'),
('can_manage', 'Can manage motions'),
("can_see", "Can see motions"),
("can_create", "Can create motions"),
("can_support", "Can support motions"),
("can_manage_metadata", "Can manage motion metadata"),
("can_manage", "Can manage motions"),
)
ordering = ('identifier', )
verbose_name = ugettext_noop('Motion')
ordering = ("identifier",)
verbose_name = ugettext_noop("Motion")
def __str__(self):
"""
@ -284,14 +289,15 @@ class Motion(RESTModelMixin, models.Model):
try:
# Always skip autoupdate. Maybe we run it later in this method.
with transaction.atomic():
super(Motion, self).save(skip_autoupdate=True, *args, **kwargs) # type: ignore
super(Motion, self).save( # type: ignore
skip_autoupdate=True, *args, **kwargs
)
except IntegrityError:
# Identifier is already used.
if hasattr(self, '_identifier_prefix'):
if hasattr(self, "_identifier_prefix"):
# Calculate a new one and try again.
self.identifier_number, self.identifier = self.increment_identifier_number(
self.identifier_number,
self._identifier_prefix,
self.identifier_number, self._identifier_prefix
)
else:
# Do not calculate a new one but reraise the IntegrityError.
@ -310,10 +316,11 @@ class Motion(RESTModelMixin, models.Model):
motion projector element is disabled.
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='motions/motion',
id=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore
skip_autoupdate=skip_autoupdate, name="motions/motion", id=self.pk
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
def set_identifier(self):
"""
@ -321,27 +328,36 @@ class Motion(RESTModelMixin, models.Model):
it is not set yet.
"""
# The identifier is already set or should be set manually.
if config['motions_identifier'] == 'manually' or self.identifier:
if config["motions_identifier"] == "manually" or self.identifier:
# Do not set an identifier.
return
# If MOTION_IDENTIFIER_WITHOUT_BLANKS is set, don't use blanks when building identifier.
without_blank = hasattr(settings, 'MOTION_IDENTIFIER_WITHOUT_BLANKS') and settings.MOTION_IDENTIFIER_WITHOUT_BLANKS
without_blank = (
hasattr(settings, "MOTION_IDENTIFIER_WITHOUT_BLANKS")
and settings.MOTION_IDENTIFIER_WITHOUT_BLANKS
)
# Build prefix.
if self.is_amendment():
parent_identifier = self.parent.identifier or ''
parent_identifier = self.parent.identifier or ""
if without_blank:
prefix = '%s%s' % (parent_identifier, config['motions_amendments_prefix'])
prefix = "%s%s" % (
parent_identifier,
config["motions_amendments_prefix"],
)
else:
prefix = '%s %s ' % (parent_identifier, config['motions_amendments_prefix'])
prefix = "%s %s " % (
parent_identifier,
config["motions_amendments_prefix"],
)
elif self.category is None or not self.category.prefix:
prefix = ''
prefix = ""
else:
if without_blank:
prefix = '%s' % self.category.prefix
prefix = "%s" % self.category.prefix
else:
prefix = '%s ' % self.category.prefix
prefix = "%s " % self.category.prefix
self._identifier_prefix = prefix
# Use the already assigned identifier_number, if the motion has one.
@ -354,20 +370,22 @@ class Motion(RESTModelMixin, models.Model):
if self.is_amendment():
motions = self.parent.amendments.all()
# The motions should be counted per category.
elif config['motions_identifier'] == 'per_category':
elif config["motions_identifier"] == "per_category":
motions = Motion.objects.filter(category=self.category)
# The motions should be counted over all.
else:
motions = Motion.objects.all()
number = motions.aggregate(Max('identifier_number'))['identifier_number__max'] or 0
number = (
motions.aggregate(Max("identifier_number"))["identifier_number__max"]
or 0
)
initial_increment = True
# Calculate new identifier.
number, identifier = self.increment_identifier_number(
number,
prefix,
initial_increment=initial_increment)
number, prefix, initial_increment=initial_increment
)
# Set identifier and identifier_number.
self.identifier = identifier
@ -380,10 +398,10 @@ class Motion(RESTModelMixin, models.Model):
"""
if initial_increment:
number += 1
identifier = '%s%s' % (prefix, self.extend_identifier_number(number))
identifier = "%s%s" % (prefix, self.extend_identifier_number(number))
while Motion.objects.filter(identifier=identifier).exists():
number += 1
identifier = '%s%s' % (prefix, self.extend_identifier_number(number))
identifier = "%s%s" % (prefix, self.extend_identifier_number(number))
return number, identifier
def extend_identifier_number(self, number):
@ -393,10 +411,18 @@ class Motion(RESTModelMixin, models.Model):
MOTION_IDENTIFIER_MIN_DIGITS.
"""
result = str(number)
if hasattr(settings, 'MOTION_IDENTIFIER_MIN_DIGITS') and settings.MOTION_IDENTIFIER_MIN_DIGITS:
if (
hasattr(settings, "MOTION_IDENTIFIER_MIN_DIGITS")
and settings.MOTION_IDENTIFIER_MIN_DIGITS
):
if not isinstance(settings.MOTION_IDENTIFIER_MIN_DIGITS, int):
raise ImproperlyConfigured('Settings value MOTION_IDENTIFIER_MIN_DIGITS must be an integer.')
result = '0' * (settings.MOTION_IDENTIFIER_MIN_DIGITS - len(str(number))) + result
raise ImproperlyConfigured(
"Settings value MOTION_IDENTIFIER_MIN_DIGITS must be an integer."
)
result = (
"0" * (settings.MOTION_IDENTIFIER_MIN_DIGITS - len(str(number)))
+ result
)
return result
def is_submitter(self, user):
@ -423,7 +449,9 @@ class Motion(RESTModelMixin, models.Model):
poll.set_options(skip_autoupdate=skip_autoupdate)
return poll
else:
raise WorkflowError('You can not create a poll in state %s.' % self.state.name)
raise WorkflowError(
"You can not create a poll in state %s." % self.state.name
)
@property
def workflow_id(self):
@ -464,8 +492,10 @@ class Motion(RESTModelMixin, models.Model):
elif self.state:
new_state = self.state.workflow.first_state
else:
new_state = (Workflow.objects.get(pk=config['motions_workflow']).first_state or
Workflow.objects.get(pk=config['motions_workflow']).states.all()[0])
new_state = (
Workflow.objects.get(pk=config["motions_workflow"]).first_state
or Workflow.objects.get(pk=config["motions_workflow"]).states.all()[0]
)
self.set_state(new_state)
def set_recommendation(self, recommendation):
@ -499,7 +529,7 @@ class Motion(RESTModelMixin, models.Model):
Note: It has to be the same return value like in JavaScript.
"""
if self.identifier:
title = '%s %s' % (_(self._meta.verbose_name), self.identifier)
title = "%s %s" % (_(self._meta.verbose_name), self.identifier)
else:
title = self.title
return title
@ -512,9 +542,9 @@ class Motion(RESTModelMixin, models.Model):
Note: It has to be the same return value like in JavaScript.
"""
if self.identifier:
title = '%s %s' % (_(self._meta.verbose_name), self.identifier)
title = "%s %s" % (_(self._meta.verbose_name), self.identifier)
else:
title = '%s (%s)' % (self.title, _(self._meta.verbose_name))
title = "%s (%s)" % (self.title, _(self._meta.verbose_name))
return title
@property
@ -552,7 +582,7 @@ class Motion(RESTModelMixin, models.Model):
A motion is a amendment if amendments are activated in the config and
the motion has a parent.
"""
return config['motions_amendments_enabled'] and self.parent is not None
return config["motions_amendments_enabled"] and self.parent is not None
def is_paragraph_based_amendment(self):
"""
@ -574,7 +604,12 @@ class Motion(RESTModelMixin, models.Model):
"""
Returns a list of all paragraph-based amendments to this motion
"""
return list(filter(lambda amend: amend.is_paragraph_based_amendment(), self.amendments.all()))
return list(
filter(
lambda amend: amend.is_paragraph_based_amendment(),
self.amendments.all(),
)
)
class MotionCommentSection(RESTModelMixin, models.Model):
@ -582,6 +617,7 @@ 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)
@ -590,17 +626,15 @@ class MotionCommentSection(RESTModelMixin, models.Model):
"""
read_groups = models.ManyToManyField(
settings.AUTH_GROUP_MODEL,
blank=True,
related_name='read_comments')
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')
settings.AUTH_GROUP_MODEL, blank=True, related_name="write_comments"
)
"""
These groups have write-access to the section.
"""
@ -621,24 +655,22 @@ class MotionComment(RESTModelMixin, models.Model):
"""
motion = models.ForeignKey(
Motion,
on_delete=models.CASCADE,
related_name='comments')
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')
MotionCommentSection, on_delete=models.PROTECT, related_name="comments"
)
"""
The section of the comment.
"""
class Meta:
default_permissions = ()
unique_together = ('motion', 'section')
unique_together = ("motion", "section")
def get_root_rest_element(self):
"""
@ -651,6 +683,7 @@ class SubmitterManager(models.Manager):
"""
Manager for Submitter model. Provides a customized add method.
"""
def add(self, user, motion, skip_autoupdate=False):
"""
Customized manager method to prevent anonymous users to be a
@ -658,13 +691,13 @@ class SubmitterManager(models.Manager):
for the initial sorting of the submitters.
"""
if self.filter(user=user, motion=motion).exists():
raise OpenSlidesError(
_('{user} is already a submitter.').format(user=user))
raise OpenSlidesError(_("{user} is already a submitter.").format(user=user))
if isinstance(user, AnonymousUser):
raise OpenSlidesError(
_('An anonymous user can not be a submitter.'))
weight = (self.filter(motion=motion).aggregate(
models.Max('weight'))['weight__max'] or 0)
raise OpenSlidesError(_("An anonymous user can not be a submitter."))
weight = (
self.filter(motion=motion).aggregate(models.Max("weight"))["weight__max"]
or 0
)
submitter = self.model(user=user, motion=motion, weight=weight + 1)
submitter.save(force_insert=True, skip_autoupdate=skip_autoupdate)
return submitter
@ -680,17 +713,14 @@ class Submitter(RESTModelMixin, models.Model):
Use custom Manager.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
"""
ForeignKey to the user who is the submitter.
"""
motion = models.ForeignKey(
Motion,
on_delete=models.CASCADE,
related_name='submitters')
Motion, on_delete=models.CASCADE, related_name="submitters"
)
"""
ForeignKey to the motion.
"""
@ -714,6 +744,7 @@ class MotionChangeRecommendationManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all change recommendations. In the background we
@ -732,9 +763,8 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
objects = MotionChangeRecommendationManager()
motion = models.ForeignKey(
Motion,
on_delete=models.CASCADE,
related_name='change_recommendations')
Motion, on_delete=models.CASCADE, related_name="change_recommendations"
)
"""The motion to which the change recommendation belongs."""
rejected = models.BooleanField(default=False)
@ -759,9 +789,8 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
"""The replacement for the section of the original text specified by motion, line_from and line_to"""
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True)
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True
)
"""A user object, who created this change recommendation. Optional."""
creation_time = models.DateTimeField(auto_now=True)
@ -769,20 +798,27 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
def collides_with_other_recommendation(self, recommendations):
for recommendation in recommendations:
if (not (self.line_from < recommendation.line_from and self.line_to <= recommendation.line_from) and
not (self.line_from >= recommendation.line_to and self.line_to > recommendation.line_to)):
if not (
self.line_from < recommendation.line_from
and self.line_to <= recommendation.line_from
) and not (
self.line_from >= recommendation.line_to
and self.line_to > recommendation.line_to
):
return True
return False
def save(self, *args, **kwargs):
recommendations = (MotionChangeRecommendation.objects
.filter(motion=self.motion)
.exclude(pk=self.pk))
recommendations = MotionChangeRecommendation.objects.filter(
motion=self.motion
).exclude(pk=self.pk)
if self.collides_with_other_recommendation(recommendations):
raise ValidationError('The recommendation collides with an existing one (line %s - %s).' %
(self.line_from, self.line_to))
raise ValidationError(
"The recommendation collides with an existing one (line %s - %s)."
% (self.line_from, self.line_to)
)
return super().save(*args, **kwargs)
@ -791,13 +827,18 @@ class MotionChangeRecommendation(RESTModelMixin, models.Model):
def __str__(self):
"""Return a string, representing this object."""
return "Recommendation for Motion %s, line %s - %s" % (self.motion_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):
"""
Model for categories of motions.
"""
access_permissions = CategoryAccessPermissions()
name = models.CharField(max_length=255)
@ -811,7 +852,7 @@ class Category(RESTModelMixin, models.Model):
class Meta:
default_permissions = ()
ordering = ['prefix']
ordering = ["prefix"]
def __str__(self):
return self.name
@ -821,18 +862,20 @@ class MotionBlockManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all motion blocks. In the
background the related agenda item is prefetched from the database.
"""
return self.get_queryset().prefetch_related('agenda_items')
return self.get_queryset().prefetch_related("agenda_items")
class MotionBlock(RESTModelMixin, models.Model):
"""
Model for blocks of motions.
"""
access_permissions = MotionBlockAccessPermissions()
objects = MotionBlockManager()
@ -841,10 +884,10 @@ class MotionBlock(RESTModelMixin, models.Model):
# 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='topics')
agenda_items = GenericRelation(Item, related_name="topics")
class Meta:
verbose_name = ugettext_noop('Motion block')
verbose_name = ugettext_noop("Motion block")
default_permissions = ()
def __str__(self):
@ -856,10 +899,11 @@ class MotionBlock(RESTModelMixin, models.Model):
motion block projector element is disabled.
"""
Projector.remove_any(
skip_autoupdate=skip_autoupdate,
name='motions/motion-block',
id=self.pk)
return super().delete(skip_autoupdate=skip_autoupdate, *args, **kwargs) # type: ignore
skip_autoupdate=skip_autoupdate, name="motions/motion-block", id=self.pk
)
return super().delete( # type: ignore
skip_autoupdate=skip_autoupdate, *args, **kwargs
)
"""
Container for runtime information for agenda app (on create or update of this instance).
@ -886,16 +930,15 @@ class MotionBlock(RESTModelMixin, models.Model):
return self.title
def get_agenda_title_with_type(self):
return '%s (%s)' % (self.get_agenda_title(), _(self._meta.verbose_name))
return "%s (%s)" % (self.get_agenda_title(), _(self._meta.verbose_name))
class MotionLog(RESTModelMixin, models.Model):
"""Save a logmessage for a motion."""
motion = models.ForeignKey(
Motion,
on_delete=models.CASCADE,
related_name='log_messages')
Motion, on_delete=models.CASCADE, related_name="log_messages"
)
"""The motion to witch the object belongs."""
message_list = JSONField()
@ -904,9 +947,8 @@ class MotionLog(RESTModelMixin, models.Model):
"""
person = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True)
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True
)
"""A user object, who created the log message. Optional."""
time = models.DateTimeField(auto_now=True)
@ -914,18 +956,20 @@ class MotionLog(RESTModelMixin, models.Model):
class Meta:
default_permissions = ()
ordering = ['-time']
ordering = ["-time"]
def __str__(self):
"""
Return a string, representing the log message.
"""
localtime = timezone.localtime(self.time)
time = formats.date_format(localtime, 'DATETIME_FORMAT')
time_and_messages = '%s ' % time + ''.join(map(_, self.message_list))
time = formats.date_format(localtime, "DATETIME_FORMAT")
time_and_messages = "%s " % time + "".join(map(_, self.message_list))
if self.person is not None:
return _('%(time_and_messages)s by %(person)s') % {'time_and_messages': time_and_messages,
'person': self.person}
return _("%(time_and_messages)s by %(person)s") % {
"time_and_messages": time_and_messages,
"person": self.person,
}
return time_and_messages
def get_root_rest_element(self):
@ -941,9 +985,7 @@ class MotionVote(RESTModelMixin, BaseVote):
There should allways be three MotionVote objects for each poll,
one for 'yes', 'no', and 'abstain'."""
option = models.ForeignKey(
'MotionOption',
on_delete=models.CASCADE)
option = models.ForeignKey("MotionOption", on_delete=models.CASCADE)
"""The option object, to witch the vote belongs."""
class Meta:
@ -961,9 +1003,7 @@ class MotionOption(RESTModelMixin, BaseOption):
There should be one MotionOption object for each poll."""
poll = models.ForeignKey(
'MotionPoll',
on_delete=models.CASCADE)
poll = models.ForeignKey("MotionPoll", on_delete=models.CASCADE)
"""The poll object, to witch the object belongs."""
vote_class = MotionVote
@ -984,16 +1024,13 @@ class MotionOption(RESTModelMixin, BaseOption):
class MotionPoll(RESTModelMixin, CollectDefaultVotesMixin, BasePoll): # type: ignore
"""The Class to saves the vote result for a motion poll."""
motion = models.ForeignKey(
Motion,
on_delete=models.CASCADE,
related_name='polls')
motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls")
"""The motion to witch the object belongs."""
option_class = MotionOption
"""The option class, witch links between this object the the votes."""
vote_values = ['Yes', 'No', 'Abstain']
vote_values = ["Yes", "No", "Abstain"]
"""The possible anwers for the poll. 'Yes, 'No' and 'Abstain'."""
class Meta:
@ -1003,7 +1040,7 @@ class MotionPoll(RESTModelMixin, CollectDefaultVotesMixin, BasePoll): # type: i
"""
Representation method only for debugging purposes.
"""
return 'MotionPoll for motion %s' % self.motion
return "MotionPoll for motion %s" % self.motion
def set_options(self, skip_autoupdate=False):
"""Create the option class for this poll."""
@ -1012,7 +1049,7 @@ class MotionPoll(RESTModelMixin, CollectDefaultVotesMixin, BasePoll): # type: i
self.get_option_class()(poll=self).save(skip_autoupdate=skip_autoupdate)
def get_percent_base_choice(self):
return config['motions_poll_100_percent_base']
return config["motions_poll_100_percent_base"]
def get_slide_context(self, **context):
return super(MotionPoll, self).get_slide_context(poll=self)
@ -1047,15 +1084,14 @@ class State(RESTModelMixin, models.Model):
"""A string for a recommendation to set the motion to this state."""
workflow = models.ForeignKey(
'Workflow',
on_delete=models.CASCADE,
related_name='states')
"Workflow", on_delete=models.CASCADE, related_name="states"
)
"""A many-to-one relation to a workflow."""
next_states = models.ManyToManyField('self', symmetrical=False, blank=True)
next_states = models.ManyToManyField("self", symmetrical=False, blank=True)
"""A many-to-many relation to all states, that can be choosen from this state."""
css_class = models.CharField(max_length=255, default='primary')
css_class = models.CharField(max_length=255, default="primary")
"""
A css class string for showing the state name in a coloured label based on bootstrap,
e.g. 'danger' (red), 'success' (green), 'warning' (yellow), 'default' (grey).
@ -1131,9 +1167,11 @@ class State(RESTModelMixin, models.Model):
recommendation_label is not an empty string.
"""
self.check_next_states()
if self.recommendation_label == '':
raise WorkflowError('The field recommendation_label of {} must not '
'be an empty string.'.format(self))
if self.recommendation_label == "":
raise WorkflowError(
"The field recommendation_label of {} must not "
"be an empty string.".format(self)
)
super(State, self).save(**kwargs)
def check_next_states(self):
@ -1143,7 +1181,10 @@ class State(RESTModelMixin, models.Model):
return
for state in self.next_states.all():
if not state.workflow == self.workflow:
raise WorkflowError('%s can not be next state of %s because it does not belong to the same workflow.' % (state, self))
raise WorkflowError(
"%s can not be next state of %s because it does not belong to the same workflow."
% (state, self)
)
def get_root_rest_element(self):
"""
@ -1156,21 +1197,25 @@ class WorkflowManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all workflows. In the background
the first state is joined and all states and next states are
prefetched from the database.
"""
return (self.get_queryset()
.select_related('first_state')
.prefetch_related('states', 'states__next_states'))
return (
self.get_queryset()
.select_related("first_state")
.prefetch_related("states", "states__next_states")
)
class Workflow(RESTModelMixin, models.Model):
"""
Defines a workflow for a motion.
"""
access_permissions = WorkflowAccessPermissions()
objects = WorkflowManager()
@ -1179,11 +1224,8 @@ class Workflow(RESTModelMixin, models.Model):
"""A string representing the workflow."""
first_state = models.OneToOneField(
State,
on_delete=models.SET_NULL,
related_name='+',
null=True,
blank=True)
State, on_delete=models.SET_NULL, related_name="+", null=True, blank=True
)
"""A one-to-one relation to a state, the starting point for the workflow."""
class Meta:
@ -1205,5 +1247,6 @@ class Workflow(RESTModelMixin, models.Model):
"""Checks whether the first_state itself belongs to the workflow."""
if self.first_state and not self.first_state.workflow == self:
raise WorkflowError(
'%s can not be first state of %s because it '
'does not belong to it.' % (self.first_state, self))
"%s can not be first state of %s because it "
"does not belong to it." % (self.first_state, self)
)

View File

@ -9,21 +9,22 @@ class MotionSlide(ProjectorElement):
"""
Slide definitions for Motion model.
"""
name = 'motions/motion'
name = "motions/motion"
def check_data(self):
if not Motion.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('Motion does not exist.')
if not Motion.objects.filter(pk=self.config_entry.get("id")).exists():
raise ProjectorException("Motion does not exist.")
def update_data(self):
data = None
try:
motion = Motion.objects.get(pk=self.config_entry.get('id'))
motion = Motion.objects.get(pk=self.config_entry.get("id"))
except Motion.DoesNotExist:
# Motion does not exist, so just do nothing.
pass
else:
data = {'agenda_item_id': motion.agenda_item_id}
data = {"agenda_item_id": motion.agenda_item_id}
return data
@ -31,21 +32,22 @@ class MotionBlockSlide(ProjectorElement):
"""
Slide definitions for a block of motions (MotionBlock model).
"""
name = 'motions/motion-block'
name = "motions/motion-block"
def check_data(self):
if not MotionBlock.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('MotionBlock does not exist.')
if not MotionBlock.objects.filter(pk=self.config_entry.get("id")).exists():
raise ProjectorException("MotionBlock does not exist.")
def update_data(self):
data = None
try:
motion_block = MotionBlock.objects.get(pk=self.config_entry.get('id'))
motion_block = MotionBlock.objects.get(pk=self.config_entry.get("id"))
except MotionBlock.DoesNotExist:
# MotionBlock does not exist, so just do nothing.
pass
else:
data = {'agenda_item_id': motion_block.agenda_item_id}
data = {"agenda_item_id": motion_block.agenda_item_id}
return data

View File

@ -39,48 +39,55 @@ def validate_workflow_field(value):
Validator to ensure that the workflow with the given id exists.
"""
if not Workflow.objects.filter(pk=value).exists():
raise ValidationError({'detail': _('Workflow %(pk)d does not exist.') % {'pk': value}})
raise ValidationError(
{"detail": _("Workflow %(pk)d does not exist.") % {"pk": value}}
)
class StatuteParagraphSerializer(ModelSerializer):
"""
Serializer for motion.models.StatuteParagraph objects.
"""
class Meta:
model = StatuteParagraph
fields = ('id', 'title', 'text', 'weight')
fields = ("id", "title", "text", "weight")
class CategorySerializer(ModelSerializer):
"""
Serializer for motion.models.Category objects.
"""
class Meta:
model = Category
fields = ('id', 'name', 'prefix',)
fields = ("id", "name", "prefix")
class MotionBlockSerializer(ModelSerializer):
"""
Serializer for motion.models.Category objects.
"""
agenda_type = IntegerField(write_only=True, required=False, min_value=1, max_value=3)
agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3
)
agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)
class Meta:
model = MotionBlock
fields = ('id', 'title', 'agenda_item_id', 'agenda_type', 'agenda_parent_id',)
fields = ("id", "title", "agenda_item_id", "agenda_type", "agenda_parent_id")
def create(self, validated_data):
"""
Customized create method. Set information about related agenda item
into agenda_item_update_information container.
"""
agenda_type = validated_data.pop('agenda_type', None)
agenda_parent_id = validated_data.pop('agenda_parent_id', None)
agenda_type = validated_data.pop("agenda_type", None)
agenda_parent_id = validated_data.pop("agenda_parent_id", None)
motion_block = MotionBlock(**validated_data)
motion_block.agenda_item_update_information['type'] = agenda_type
motion_block.agenda_item_update_information['parent_id'] = agenda_parent_id
motion_block.agenda_item_update_information["type"] = agenda_type
motion_block.agenda_item_update_information["parent_id"] = agenda_parent_id
motion_block.save()
return motion_block
@ -89,35 +96,38 @@ class StateSerializer(ModelSerializer):
"""
Serializer for motion.models.State objects.
"""
class Meta:
model = State
fields = (
'id',
'name',
'recommendation_label',
'css_class',
'required_permission_to_see',
'allow_support',
'allow_create_poll',
'allow_submitter_edit',
'dont_set_identifier',
'show_state_extension_field',
'merge_amendment_into_final',
'show_recommendation_extension_field',
'next_states',
'workflow')
"id",
"name",
"recommendation_label",
"css_class",
"required_permission_to_see",
"allow_support",
"allow_create_poll",
"allow_submitter_edit",
"dont_set_identifier",
"show_state_extension_field",
"merge_amendment_into_final",
"show_recommendation_extension_field",
"next_states",
"workflow",
)
class WorkflowSerializer(ModelSerializer):
"""
Serializer for motion.models.Workflow objects.
"""
states = StateSerializer(many=True, read_only=True)
class Meta:
model = Workflow
fields = ('id', 'name', 'states', 'first_state',)
read_only_fields = ('first_state',)
fields = ("id", "name", "states", "first_state")
read_only_fields = ("first_state",)
@transaction.atomic
def create(self, validated_data):
@ -127,11 +137,11 @@ class WorkflowSerializer(ModelSerializer):
"""
workflow = super().create(validated_data)
first_state = State.objects.create(
name='new',
name="new",
workflow=workflow,
allow_create_poll=True,
allow_support=True,
allow_submitter_edit=True
allow_submitter_edit=True,
)
workflow.first_state = first_state
workflow.save()
@ -142,6 +152,7 @@ class AmendmentParagraphsJSONSerializerField(Field):
"""
Serializer for motions's amendment_paragraphs JSONField.
"""
def to_representation(self, obj):
"""
Returns the value of the field.
@ -153,10 +164,12 @@ class AmendmentParagraphsJSONSerializerField(Field):
Checks that data is a list of strings.
"""
if type(data) is not list:
raise ValidationError({'detail': 'Data must be a list.'})
raise ValidationError({"detail": "Data must be a list."})
for paragraph in data:
if type(paragraph) is not str and paragraph is not None:
raise ValidationError({'detail': 'Paragraph must be either a string or null/None.'})
raise ValidationError(
{"detail": "Paragraph must be either a string or null/None."}
)
return data
@ -164,11 +177,12 @@ class MotionLogSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionLog objects.
"""
message = SerializerMethodField()
class Meta:
model = MotionLog
fields = ('message_list', 'person', 'time', 'message',)
fields = ("message_list", "person", "time", "message")
def get_message(self, obj):
"""
@ -181,27 +195,32 @@ class MotionPollSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionPoll objects.
"""
yes = SerializerMethodField()
no = SerializerMethodField()
abstain = SerializerMethodField()
votes = DictField(
child=DecimalField(max_digits=15, decimal_places=6, min_value=-2, allow_null=True),
write_only=True)
child=DecimalField(
max_digits=15, decimal_places=6, min_value=-2, allow_null=True
),
write_only=True,
)
has_votes = SerializerMethodField()
class Meta:
model = MotionPoll
fields = (
'id',
'motion',
'yes',
'no',
'abstain',
'votesvalid',
'votesinvalid',
'votescast',
'votes',
'has_votes')
"id",
"motion",
"yes",
"no",
"abstain",
"votesvalid",
"votesinvalid",
"votescast",
"votes",
"has_votes",
)
validators = (default_votes_validator,)
def __init__(self, *args, **kwargs):
@ -211,21 +230,21 @@ class MotionPollSerializer(ModelSerializer):
def get_yes(self, obj):
try:
result: Optional[str] = str(self.get_votes_dict(obj)['Yes'])
result: Optional[str] = str(self.get_votes_dict(obj)["Yes"])
except KeyError:
result = None
return result
def get_no(self, obj):
try:
result: Optional[str] = str(self.get_votes_dict(obj)['No'])
result: Optional[str] = str(self.get_votes_dict(obj)["No"])
except KeyError:
result = None
return result
def get_abstain(self, obj):
try:
result: Optional[str] = str(self.get_votes_dict(obj)['Abstain'])
result: Optional[str] = str(self.get_votes_dict(obj)["Abstain"])
except KeyError:
result = None
return result
@ -256,21 +275,30 @@ class MotionPollSerializer(ModelSerializer):
"votes": {"Yes": 10, "No": 4, "Abstain": -2}
"""
# Update votes.
votes = validated_data.get('votes')
votes = validated_data.get("votes")
if votes:
if len(votes) != len(instance.get_vote_values()):
raise ValidationError({
'detail': _('You have to submit data for %d vote values.') % len(instance.get_vote_values())})
raise ValidationError(
{
"detail": _("You have to submit data for %d vote values.")
% len(instance.get_vote_values())
}
)
for vote_value, vote_weight in votes.items():
if vote_value not in instance.get_vote_values():
raise ValidationError({
'detail': _('Vote value %s is invalid.') % vote_value})
instance.set_vote_objects_with_values(instance.get_options().get(), votes, skip_autoupdate=True)
raise ValidationError(
{"detail": _("Vote value %s is invalid.") % vote_value}
)
instance.set_vote_objects_with_values(
instance.get_options().get(), votes, skip_autoupdate=True
)
# Update remaining writeable fields.
instance.votesvalid = validated_data.get('votesvalid', instance.votesvalid)
instance.votesinvalid = validated_data.get('votesinvalid', instance.votesinvalid)
instance.votescast = validated_data.get('votescast', instance.votescast)
instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid)
instance.votesinvalid = validated_data.get(
"votesinvalid", instance.votesinvalid
)
instance.votescast = validated_data.get("votescast", instance.votescast)
instance.save()
return instance
@ -279,27 +307,29 @@ class MotionChangeRecommendationSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionChangeRecommendation objects.
"""
class Meta:
model = MotionChangeRecommendation
fields = (
'id',
'motion',
'rejected',
'internal',
'type',
'other_description',
'line_from',
'line_to',
'text',
'creation_time',)
"id",
"motion",
"rejected",
"internal",
"type",
"other_description",
"line_from",
"line_to",
"text",
"creation_time",
)
def is_title_cr(self, data):
return int(data['line_from']) == 0 and int(data['line_to']) == 0
return int(data["line_from"]) == 0 and int(data["line_to"]) == 0
def validate(self, data):
# Change recommendations for titles are stored as plain-text, thus they don't need to be html-escaped
if 'text' in data and not self.is_title_cr(data):
data['text'] = validate_html(data['text'])
if "text" in data and not self.is_title_cr(data):
data["text"] = validate_html(data["text"])
return data
@ -307,23 +337,18 @@ class MotionCommentSectionSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionCommentSection objects.
"""
read_groups = IdPrimaryKeyRelatedField(
many=True,
required=False,
queryset=get_group_model().objects.all())
many=True, required=False, queryset=get_group_model().objects.all()
)
write_groups = IdPrimaryKeyRelatedField(
many=True,
required=False,
queryset=get_group_model().objects.all())
many=True, required=False, queryset=get_group_model().objects.all()
)
class Meta:
model = MotionCommentSection
fields = (
'id',
'name',
'read_groups',
'write_groups',)
fields = ("id", "name", "read_groups", "write_groups")
def create(self, validated_data):
""" Call inform_changed_data on creation, so the cache includes the groups. """
@ -336,15 +361,12 @@ class MotionCommentSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionComment objects.
"""
read_groups_id = SerializerMethodField()
class Meta:
model = MotionComment
fields = (
'id',
'comment',
'section',
'read_groups_id',)
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()]
@ -354,20 +376,17 @@ class SubmitterSerializer(ModelSerializer):
"""
Serializer for motion.models.Submitter objects.
"""
class Meta:
model = Submitter
fields = (
'id',
'user',
'motion',
'weight',
)
fields = ("id", "user", "motion", "weight")
class MotionSerializer(ModelSerializer):
"""
Serializer for motion.models.Motion objects.
"""
comments = MotionCommentSerializer(many=True, read_only=True)
log_messages = MotionLogSerializer(many=True, read_only=True)
polls = MotionPollSerializer(many=True, read_only=True)
@ -378,67 +397,76 @@ class MotionSerializer(ModelSerializer):
title = CharField(max_length=255)
amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False)
workflow_id = IntegerField(
min_value=1,
required=False,
validators=[validate_workflow_field])
agenda_type = IntegerField(write_only=True, required=False, min_value=1, max_value=3)
min_value=1, required=False, validators=[validate_workflow_field]
)
agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3
)
agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)
submitters = SubmitterSerializer(many=True, read_only=True)
class Meta:
model = Motion
fields = (
'id',
'identifier',
'title',
'text',
'amendment_paragraphs',
'modified_final_version',
'reason',
'parent',
'category',
'comments',
'motion_block',
'origin',
'submitters',
'supporters',
'state',
'state_extension',
'state_required_permission_to_see',
'statute_paragraph',
'workflow_id',
'recommendation',
'recommendation_extension',
'tags',
'attachments',
'polls',
'agenda_item_id',
'agenda_type',
'agenda_parent_id',
'log_messages',
'sort_parent',
'weight',)
read_only_fields = ('state', 'recommendation',) # Some other fields are also read_only. See definitions above.
"id",
"identifier",
"title",
"text",
"amendment_paragraphs",
"modified_final_version",
"reason",
"parent",
"category",
"comments",
"motion_block",
"origin",
"submitters",
"supporters",
"state",
"state_extension",
"state_required_permission_to_see",
"statute_paragraph",
"workflow_id",
"recommendation",
"recommendation_extension",
"tags",
"attachments",
"polls",
"agenda_item_id",
"agenda_type",
"agenda_parent_id",
"log_messages",
"sort_parent",
"weight",
)
read_only_fields = (
"state",
"recommendation",
) # Some other fields are also read_only. See definitions above.
def validate(self, data):
if 'text'in data:
data['text'] = validate_html(data['text'])
if "text" in data:
data["text"] = validate_html(data["text"])
if 'modified_final_version' in data:
data['modified_final_version'] = validate_html(data['modified_final_version'])
if "modified_final_version" in data:
data["modified_final_version"] = validate_html(
data["modified_final_version"]
)
if 'reason' in data:
data['reason'] = validate_html(data['reason'])
if "reason" in data:
data["reason"] = validate_html(data["reason"])
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']))
data['text'] = ''
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"],
)
)
data["text"] = ""
else:
if 'text' in data and len(data['text']) == 0:
raise ValidationError({
'detail': _('This field may not be blank.')
})
if "text" in data and len(data["text"]) == 0:
raise ValidationError({"detail": _("This field may not be blank.")})
return data
@ -451,24 +479,28 @@ class MotionSerializer(ModelSerializer):
agenda_item_update_information container.
"""
motion = Motion()
motion.title = validated_data['title']
motion.text = validated_data['text']
motion.amendment_paragraphs = validated_data.get('amendment_paragraphs')
motion.modified_final_version = validated_data.get('modified_final_version', '')
motion.reason = validated_data.get('reason', '')
motion.identifier = validated_data.get('identifier')
motion.category = validated_data.get('category')
motion.motion_block = validated_data.get('motion_block')
motion.origin = validated_data.get('origin', '')
motion.parent = validated_data.get('parent')
motion.statute_paragraph = validated_data.get('statute_paragraph')
motion.reset_state(validated_data.get('workflow_id'))
motion.agenda_item_update_information['type'] = validated_data.get('agenda_type')
motion.agenda_item_update_information['parent_id'] = validated_data.get('agenda_parent_id')
motion.title = validated_data["title"]
motion.text = validated_data["text"]
motion.amendment_paragraphs = validated_data.get("amendment_paragraphs")
motion.modified_final_version = validated_data.get("modified_final_version", "")
motion.reason = validated_data.get("reason", "")
motion.identifier = validated_data.get("identifier")
motion.category = validated_data.get("category")
motion.motion_block = validated_data.get("motion_block")
motion.origin = validated_data.get("origin", "")
motion.parent = validated_data.get("parent")
motion.statute_paragraph = validated_data.get("statute_paragraph")
motion.reset_state(validated_data.get("workflow_id"))
motion.agenda_item_update_information["type"] = validated_data.get(
"agenda_type"
)
motion.agenda_item_update_information["parent_id"] = validated_data.get(
"agenda_parent_id"
)
motion.save()
motion.supporters.add(*validated_data.get('supporters', []))
motion.attachments.add(*validated_data.get('attachments', []))
motion.tags.add(*validated_data.get('tags', []))
motion.supporters.add(*validated_data.get("supporters", []))
motion.attachments.add(*validated_data.get("attachments", []))
motion.tags.add(*validated_data.get("tags", []))
return motion
@transaction.atomic
@ -477,8 +509,8 @@ class MotionSerializer(ModelSerializer):
Customized method to update a motion.
"""
workflow_id = None
if 'workflow_id' in validated_data:
workflow_id = validated_data.pop('workflow_id')
if "workflow_id" in validated_data:
workflow_id = validated_data.pop("workflow_id")
result = super().update(motion, validated_data)

View File

@ -14,98 +14,128 @@ def create_builtin_workflows(sender, **kwargs):
# If there is at least one workflow, then do nothing.
return
workflow_1 = Workflow(name='Simple Workflow')
workflow_1 = Workflow(name="Simple Workflow")
workflow_1.save(skip_autoupdate=True)
state_1_1 = State(name=ugettext_noop('submitted'),
workflow=workflow_1,
allow_create_poll=True,
allow_support=True,
allow_submitter_edit=True)
state_1_1 = State(
name=ugettext_noop("submitted"),
workflow=workflow_1,
allow_create_poll=True,
allow_support=True,
allow_submitter_edit=True,
)
state_1_1.save(skip_autoupdate=True)
state_1_2 = State(name=ugettext_noop('accepted'),
workflow=workflow_1,
recommendation_label='Acceptance',
css_class='success',
merge_amendment_into_final=1)
state_1_2 = State(
name=ugettext_noop("accepted"),
workflow=workflow_1,
recommendation_label="Acceptance",
css_class="success",
merge_amendment_into_final=1,
)
state_1_2.save(skip_autoupdate=True)
state_1_3 = State(name=ugettext_noop('rejected'),
workflow=workflow_1,
recommendation_label='Rejection',
css_class='danger',
merge_amendment_into_final=-1)
state_1_3 = State(
name=ugettext_noop("rejected"),
workflow=workflow_1,
recommendation_label="Rejection",
css_class="danger",
merge_amendment_into_final=-1,
)
state_1_3.save(skip_autoupdate=True)
state_1_4 = State(name=ugettext_noop('not decided'),
workflow=workflow_1,
recommendation_label='No decision',
css_class='default',
merge_amendment_into_final=-1)
state_1_4 = State(
name=ugettext_noop("not decided"),
workflow=workflow_1,
recommendation_label="No decision",
css_class="default",
merge_amendment_into_final=-1,
)
state_1_4.save(skip_autoupdate=True)
state_1_1.next_states.add(state_1_2, state_1_3, state_1_4)
workflow_1.first_state = state_1_1
workflow_1.save(skip_autoupdate=True)
workflow_2 = Workflow(name='Complex Workflow')
workflow_2 = Workflow(name="Complex Workflow")
workflow_2.save(skip_autoupdate=True)
state_2_1 = State(name=ugettext_noop('published'),
workflow=workflow_2,
allow_support=True,
allow_submitter_edit=True,
dont_set_identifier=True)
state_2_1 = State(
name=ugettext_noop("published"),
workflow=workflow_2,
allow_support=True,
allow_submitter_edit=True,
dont_set_identifier=True,
)
state_2_1.save(skip_autoupdate=True)
state_2_2 = State(name=ugettext_noop('permitted'),
workflow=workflow_2,
recommendation_label='Permission',
allow_create_poll=True,
allow_submitter_edit=True)
state_2_2 = State(
name=ugettext_noop("permitted"),
workflow=workflow_2,
recommendation_label="Permission",
allow_create_poll=True,
allow_submitter_edit=True,
)
state_2_2.save(skip_autoupdate=True)
state_2_3 = State(name=ugettext_noop('accepted'),
workflow=workflow_2,
recommendation_label='Acceptance',
css_class='success',
merge_amendment_into_final=1)
state_2_3 = State(
name=ugettext_noop("accepted"),
workflow=workflow_2,
recommendation_label="Acceptance",
css_class="success",
merge_amendment_into_final=1,
)
state_2_3.save(skip_autoupdate=True)
state_2_4 = State(name=ugettext_noop('rejected'),
workflow=workflow_2,
recommendation_label='Rejection',
css_class='danger',
merge_amendment_into_final=-1)
state_2_4 = State(
name=ugettext_noop("rejected"),
workflow=workflow_2,
recommendation_label="Rejection",
css_class="danger",
merge_amendment_into_final=-1,
)
state_2_4.save(skip_autoupdate=True)
state_2_5 = State(name=ugettext_noop('withdrawed'),
workflow=workflow_2,
css_class='default',
merge_amendment_into_final=-1)
state_2_5 = State(
name=ugettext_noop("withdrawed"),
workflow=workflow_2,
css_class="default",
merge_amendment_into_final=-1,
)
state_2_5.save(skip_autoupdate=True)
state_2_6 = State(name=ugettext_noop('adjourned'),
workflow=workflow_2,
recommendation_label='Adjournment',
css_class='default',
merge_amendment_into_final=-1)
state_2_6 = State(
name=ugettext_noop("adjourned"),
workflow=workflow_2,
recommendation_label="Adjournment",
css_class="default",
merge_amendment_into_final=-1,
)
state_2_6.save(skip_autoupdate=True)
state_2_7 = State(name=ugettext_noop('not concerned'),
workflow=workflow_2,
recommendation_label='No concernment',
css_class='default',
merge_amendment_into_final=-1)
state_2_7 = State(
name=ugettext_noop("not concerned"),
workflow=workflow_2,
recommendation_label="No concernment",
css_class="default",
merge_amendment_into_final=-1,
)
state_2_7.save(skip_autoupdate=True)
state_2_8 = State(name=ugettext_noop('refered to committee'),
workflow=workflow_2,
recommendation_label='Referral to committee',
css_class='default',
merge_amendment_into_final=-1)
state_2_8 = State(
name=ugettext_noop("refered to committee"),
workflow=workflow_2,
recommendation_label="Referral to committee",
css_class="default",
merge_amendment_into_final=-1,
)
state_2_8.save(skip_autoupdate=True)
state_2_9 = State(name=ugettext_noop('needs review'),
workflow=workflow_2,
css_class='default',
merge_amendment_into_final=-1)
state_2_9 = State(
name=ugettext_noop("needs review"),
workflow=workflow_2,
css_class="default",
merge_amendment_into_final=-1,
)
state_2_9.save(skip_autoupdate=True)
state_2_10 = State(name=ugettext_noop('rejected (not authorized)'),
workflow=workflow_2,
recommendation_label='Rejection (not authorized)',
css_class='default',
merge_amendment_into_final=-1)
state_2_10 = State(
name=ugettext_noop("rejected (not authorized)"),
workflow=workflow_2,
recommendation_label="Rejection (not authorized)",
css_class="default",
merge_amendment_into_final=-1,
)
state_2_10.save(skip_autoupdate=True)
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)
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
)
workflow_2.first_state = state_2_1
workflow_2.save(skip_autoupdate=True)
@ -114,8 +144,11 @@ def get_permission_change_data(sender, permissions, **kwargs):
"""
Yields all necessary collections if 'motions.can_see' permission changes.
"""
motions_app = apps.get_app_config(app_label='motions')
motions_app = apps.get_app_config(app_label="motions")
for permission in permissions:
# There could be only one 'motions.can_see' and then we want to return data.
if permission.content_type.app_label == motions_app.label and permission.codename == 'can_see':
if (
permission.content_type.app_label == motions_app.label
and permission.codename == "can_see"
):
yield from motions_app.get_startup_elements()

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
# Common majority methods for all apps using polls. The first one should be the default.
majorityMethods = (
{'value': 'simple_majority', 'display_name': 'Simple majority'},
{'value': 'two-thirds_majority', 'display_name': 'Two-thirds majority'},
{'value': 'three-quarters_majority', 'display_name': 'Three-quarters majority'},
{'value': 'disabled', 'display_name': 'Disabled'},
{"value": "simple_majority", "display_name": "Simple majority"},
{"value": "two-thirds_majority", "display_name": "Two-thirds majority"},
{"value": "three-quarters_majority", "display_name": "Three-quarters majority"},
{"value": "disabled", "display_name": "Disabled"},
)

View File

@ -17,7 +17,8 @@ class BaseOption(models.Model):
which has to be a subclass of BaseVote. Otherwise you have to override the
get_vote_class method.
"""
vote_class: Optional[Type['BaseVote']] = None
vote_class: Optional[Type["BaseVote"]] = None
class Meta:
abstract = True
@ -27,7 +28,9 @@ class BaseOption(models.Model):
def get_vote_class(self):
if self.vote_class is None:
raise NotImplementedError('The option class %s has to have an attribute vote_class.' % self)
raise NotImplementedError(
"The option class %s has to have an attribute vote_class." % self
)
return self.vote_class
def __getitem__(self, name):
@ -44,8 +47,14 @@ class BaseVote(models.Model):
Subclasses have to define an option field. This must be a ForeignKeyField
to a subclass of BasePoll.
"""
weight = models.DecimalField(default=Decimal('1'), null=True, validators=[
MinValueValidator(Decimal('-2'))], max_digits=15, decimal_places=6)
weight = models.DecimalField(
default=Decimal("1"),
null=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
value = models.CharField(max_length=255, null=True)
class Meta:
@ -73,12 +82,28 @@ class CollectDefaultVotesMixin(models.Model):
Mixin for a poll to collect the default vote values for valid votes,
invalid votes and votes cast.
"""
votesvalid = models.DecimalField(null=True, blank=True, validators=[
MinValueValidator(Decimal('-2'))], max_digits=15, decimal_places=6)
votesinvalid = models.DecimalField(null=True, blank=True, validators=[
MinValueValidator(Decimal('-2'))], max_digits=15, decimal_places=6)
votescast = models.DecimalField(null=True, blank=True, validators=[
MinValueValidator(Decimal('-2'))], max_digits=15, decimal_places=6)
votesvalid = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
votesinvalid = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
votescast = models.DecimalField(
null=True,
blank=True,
validators=[MinValueValidator(Decimal("-2"))],
max_digits=15,
decimal_places=6,
)
class Meta:
abstract = True
@ -87,13 +112,16 @@ class CollectDefaultVotesMixin(models.Model):
"""
Returns one of the strings of the percent base.
"""
raise NotImplementedError('You have to provide a get_percent_base_choice() method.')
raise NotImplementedError(
"You have to provide a get_percent_base_choice() method."
)
class PublishPollMixin(models.Model):
"""
Mixin for a poll to add a flag whether the poll is published or not.
"""
published = models.BooleanField(default=False)
class Meta:
@ -108,7 +136,8 @@ class BasePoll(models.Model):
"""
Base poll class.
"""
vote_values = ['Votes']
vote_values = ["Votes"]
class Meta:
abstract = True
@ -183,7 +212,7 @@ class BasePoll(models.Model):
try:
vote = self.get_votes().filter(option=option_id).get(value=value)
except ObjectDoesNotExist:
values.append(self.get_vote_class()(value=value, weight=''))
values.append(self.get_vote_class()(value=value, weight=""))
else:
values.append(vote)
return values
@ -195,15 +224,18 @@ def print_value(value, percent_base=0):
'undocumented' or the vote value with percent value if so.
"""
if value == -1:
verbose_value = _('majority')
verbose_value = _("majority")
elif value == -2:
verbose_value = _('undocumented')
verbose_value = _("undocumented")
elif value is None:
verbose_value = _('undocumented')
verbose_value = _("undocumented")
else:
if percent_base:
locale.setlocale(locale.LC_ALL, '')
verbose_value = u'%d (%s %%)' % (value, locale.format('%.1f', value * percent_base))
locale.setlocale(locale.LC_ALL, "")
verbose_value = "%d (%s %%)" % (
value,
locale.format("%.1f", value * percent_base),
)
else:
verbose_value = u'%s' % value
verbose_value = "%s" % value
return verbose_value

View File

@ -10,8 +10,12 @@ def default_votes_validator(data):
than or equal to -2.
"""
for key in data:
if (key in ('votesvalid', 'votesinvalid', 'votescast') and
data[key] is not None and
data[key] < -2):
raise ValidationError({'detail': _('Value for {} must not be less than -2').format(key)})
if (
key in ("votesvalid", "votesinvalid", "votescast")
and data[key] is not None
and data[key] < -2
):
raise ValidationError(
{"detail": _("Value for {} must not be less than -2").format(key)}
)
return data

View File

@ -5,11 +5,9 @@ from openslides.utils.consumers import SiteConsumer
from openslides.utils.middleware import AuthMiddlewareStack
application = ProtocolTypeRouter({
# WebSocket chat handler
"websocket": AuthMiddlewareStack(
URLRouter([
url(r"^ws/$", SiteConsumer),
])
)
})
application = ProtocolTypeRouter(
{
# WebSocket chat handler
"websocket": AuthMiddlewareStack(URLRouter([url(r"^ws/$", SiteConsumer)]))
}
)

Some files were not shown because too many files have changed in this diff Show More