commit
1e8e5334a5
@ -6,8 +6,10 @@
|
|||||||
The OpenSlides agenda app appends the functionality to OpenSlides to
|
The OpenSlides agenda app appends the functionality to OpenSlides to
|
||||||
manage agendas.
|
manage agendas.
|
||||||
|
|
||||||
|
It includes time-management and list of speakers.
|
||||||
|
|
||||||
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.
|
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.
|
||||||
:license: GNU GPL, see LICENSE for more details.
|
:license: GNU GPL, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import signals
|
from . import signals, slides
|
||||||
|
@ -17,7 +17,8 @@ from django.utils.translation import ugettext_lazy
|
|||||||
from mptt.forms import TreeNodeChoiceField
|
from mptt.forms import TreeNodeChoiceField
|
||||||
|
|
||||||
from openslides.utils.forms import CssClassMixin
|
from openslides.utils.forms import CssClassMixin
|
||||||
from .models import Item
|
from openslides.utils.person.forms import PersonFormField
|
||||||
|
from .models import Item, Speaker
|
||||||
|
|
||||||
|
|
||||||
class ItemForm(forms.ModelForm, CssClassMixin):
|
class ItemForm(forms.ModelForm, CssClassMixin):
|
||||||
@ -57,3 +58,27 @@ class ItemOrderForm(CssClassMixin, forms.Form):
|
|||||||
widget=forms.HiddenInput(attrs={'class': 'menu-mlid'}))
|
widget=forms.HiddenInput(attrs={'class': 'menu-mlid'}))
|
||||||
parent = forms.IntegerField(
|
parent = forms.IntegerField(
|
||||||
widget=forms.HiddenInput(attrs={'class': 'menu-plid'}))
|
widget=forms.HiddenInput(attrs={'class': 'menu-plid'}))
|
||||||
|
|
||||||
|
|
||||||
|
class AppendSpeakerForm(CssClassMixin, forms.Form):
|
||||||
|
"""
|
||||||
|
Form to set an user to a list of speakers.
|
||||||
|
"""
|
||||||
|
speaker = PersonFormField(
|
||||||
|
widget=forms.Select(attrs={'class': 'medium-input'}),
|
||||||
|
label=ugettext_lazy("Add participant"))
|
||||||
|
|
||||||
|
def __init__(self, item, *args, **kwargs):
|
||||||
|
self.item = item
|
||||||
|
return super(AppendSpeakerForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_speaker(self):
|
||||||
|
"""
|
||||||
|
Checks, that the user is not already on the list.
|
||||||
|
"""
|
||||||
|
speaker = self.cleaned_data['speaker']
|
||||||
|
if Speaker.objects.filter(person=speaker, item=self.item, time=None).exists():
|
||||||
|
raise forms.ValidationError(ugettext_lazy(
|
||||||
|
'%s is already on the list of speakers.'
|
||||||
|
% unicode(speaker)))
|
||||||
|
return speaker
|
||||||
|
@ -10,17 +10,20 @@
|
|||||||
:license: GNU GPL, see LICENSE for more details.
|
:license: GNU GPL, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.utils.translation import ugettext_lazy as _, ugettext_noop, ugettext
|
from django.utils.translation import ugettext_lazy as _, ugettext_noop, ugettext
|
||||||
|
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
|
||||||
|
from openslides.utils.exceptions import OpenSlidesError
|
||||||
from openslides.config.api import config
|
from openslides.config.api import config
|
||||||
from openslides.projector.projector import SlideMixin
|
from openslides.projector.projector import SlideMixin
|
||||||
from openslides.projector.api import (
|
from openslides.projector.api import (
|
||||||
register_slidemodel, get_slide_from_sid, register_slidefunc)
|
register_slidemodel, get_slide_from_sid, register_slidefunc)
|
||||||
from .slides import agenda_show
|
from openslides.utils.person.models import PersonField
|
||||||
|
|
||||||
|
|
||||||
class Item(MPTTModel, SlideMixin):
|
class Item(MPTTModel, SlideMixin):
|
||||||
@ -39,15 +42,62 @@ class Item(MPTTModel, SlideMixin):
|
|||||||
(ORGANIZATIONAL_ITEM, _('Organizational item')))
|
(ORGANIZATIONAL_ITEM, _('Organizational item')))
|
||||||
|
|
||||||
title = models.CharField(null=True, max_length=255, verbose_name=_("Title"))
|
title = models.CharField(null=True, max_length=255, verbose_name=_("Title"))
|
||||||
|
"""
|
||||||
|
Title of the agenda item.
|
||||||
|
"""
|
||||||
|
|
||||||
text = models.TextField(null=True, blank=True, verbose_name=_("Text"))
|
text = models.TextField(null=True, blank=True, verbose_name=_("Text"))
|
||||||
|
"""
|
||||||
|
The optional text of the agenda item.
|
||||||
|
"""
|
||||||
|
|
||||||
comment = models.TextField(null=True, blank=True, verbose_name=_("Comment"))
|
comment = models.TextField(null=True, blank=True, verbose_name=_("Comment"))
|
||||||
|
"""
|
||||||
|
Optional comment to the agenda item. Will not be shoun to normal users.
|
||||||
|
"""
|
||||||
|
|
||||||
closed = models.BooleanField(default=False, verbose_name=_("Closed"))
|
closed = models.BooleanField(default=False, verbose_name=_("Closed"))
|
||||||
|
"""
|
||||||
|
Flag, if the item is finished.
|
||||||
|
"""
|
||||||
|
|
||||||
weight = models.IntegerField(default=0, verbose_name=_("Weight"))
|
weight = models.IntegerField(default=0, verbose_name=_("Weight"))
|
||||||
|
"""
|
||||||
|
Weight to sort the item in the agenda.
|
||||||
|
"""
|
||||||
|
|
||||||
parent = TreeForeignKey('self', null=True, blank=True,
|
parent = TreeForeignKey('self', null=True, blank=True,
|
||||||
related_name='children')
|
related_name='children')
|
||||||
type = models.IntegerField(max_length=1, choices=ITEM_TYPE, default=AGENDA_ITEM, verbose_name=_("Type"))
|
"""
|
||||||
duration = models.CharField(null=True, blank=True, max_length=5, verbose_name=_("Duration (hh:mm)"))
|
The parent item in the agenda tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type = models.IntegerField(max_length=1, choices=ITEM_TYPE,
|
||||||
|
default=AGENDA_ITEM, verbose_name=_("Type"))
|
||||||
|
"""
|
||||||
|
Type of the agenda item.
|
||||||
|
|
||||||
|
See Agenda.ITEM_TYPE for more informations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
duration = models.CharField(null=True, blank=True, max_length=5,
|
||||||
|
verbose_name=_("Duration (hh:mm)"))
|
||||||
|
"""
|
||||||
|
The intended duration for the topic.
|
||||||
|
"""
|
||||||
|
|
||||||
related_sid = models.CharField(null=True, blank=True, max_length=63)
|
related_sid = models.CharField(null=True, blank=True, max_length=63)
|
||||||
|
"""
|
||||||
|
Slide-ID to another object to show it in the agenda.
|
||||||
|
|
||||||
|
For example a motion or assignment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
speaker_list_closed = models.BooleanField(
|
||||||
|
default=False, verbose_name=_("List of speakers is closed"))
|
||||||
|
"""
|
||||||
|
True, if the list of speakers is closed.
|
||||||
|
"""
|
||||||
|
|
||||||
def get_related_slide(self):
|
def get_related_slide(self):
|
||||||
"""
|
"""
|
||||||
@ -106,6 +156,11 @@ class Item(MPTTModel, SlideMixin):
|
|||||||
'items': self.get_children(),
|
'items': self.get_children(),
|
||||||
'template': 'projector/AgendaSummary.html',
|
'template': 'projector/AgendaSummary.html',
|
||||||
}
|
}
|
||||||
|
elif config['presentation_argument'] == 'show_list_of_speakers':
|
||||||
|
speakers = Speaker.objects.filter(time=None, item=self.pk).order_by('weight')
|
||||||
|
data = {'title': _('List of speakers for %s') % self.get_title(),
|
||||||
|
'template': 'projector/agenda_list_of_speaker.html',
|
||||||
|
'speakers': speakers}
|
||||||
elif self.related_sid:
|
elif self.related_sid:
|
||||||
data = self.get_related_slide().slide()
|
data = self.get_related_slide().slide()
|
||||||
else:
|
else:
|
||||||
@ -186,5 +241,63 @@ class Item(MPTTModel, SlideMixin):
|
|||||||
order_insertion_by = ['weight']
|
order_insertion_by = ['weight']
|
||||||
|
|
||||||
|
|
||||||
register_slidemodel(Item, control_template='agenda/control_item.html')
|
class SpeakerManager(models.Manager):
|
||||||
register_slidefunc('agenda', agenda_show, weight=-1, name=_('Agenda'))
|
def add(self, person, item):
|
||||||
|
if self.filter(person=person, item=item, time=None).exists():
|
||||||
|
raise OpenSlidesError(_('%s is already on the list of speakers of item %d.') % (person, item.id))
|
||||||
|
weight = (self.filter(item=item).aggregate(
|
||||||
|
models.Max('weight'))['weight__max'] or 0)
|
||||||
|
return self.create(item=item, person=person, weight=weight + 1)
|
||||||
|
|
||||||
|
|
||||||
|
class Speaker(models.Model):
|
||||||
|
"""
|
||||||
|
Model for the Speaker list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
objects = SpeakerManager()
|
||||||
|
|
||||||
|
person = PersonField()
|
||||||
|
"""
|
||||||
|
ForeinKey to the person who speaks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
item = models.ForeignKey(Item)
|
||||||
|
"""
|
||||||
|
ForeinKey to the AgendaItem to which the person want to speak.
|
||||||
|
"""
|
||||||
|
|
||||||
|
time = models.DateTimeField(null=True)
|
||||||
|
"""
|
||||||
|
Saves the time, when the speaker has spoken. None, if he has not spoken yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
weight = models.IntegerField(null=True)
|
||||||
|
"""
|
||||||
|
The sort order of the list of speakers. None, if he has already spoken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
permissions = (
|
||||||
|
('can_be_speaker', ugettext_noop('Can be speaker')),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return unicode(self.person)
|
||||||
|
|
||||||
|
def get_absolute_url(self, link='detail'):
|
||||||
|
if link == 'detail' or link == 'view':
|
||||||
|
return self.person.get_absolute_url('detail')
|
||||||
|
if link == 'delete':
|
||||||
|
return reverse('agenda_speaker_delete',
|
||||||
|
args=[self.item.pk, self.pk])
|
||||||
|
|
||||||
|
def speak(self):
|
||||||
|
"""
|
||||||
|
Let the person speak.
|
||||||
|
|
||||||
|
Set the weight to None and the time to now.
|
||||||
|
"""
|
||||||
|
self.weight = None
|
||||||
|
self.time = datetime.now()
|
||||||
|
self.save()
|
||||||
|
@ -13,10 +13,17 @@
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import ugettext_lazy, ugettext_noop, ugettext as _
|
from django.utils.translation import ugettext_lazy, ugettext_noop, ugettext as _
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from openslides.config.signals import config_signal
|
from openslides.config.signals import config_signal
|
||||||
from openslides.config.api import ConfigVariable, ConfigPage
|
from openslides.config.api import ConfigVariable, ConfigPage
|
||||||
|
|
||||||
|
from openslides.projector.signals import projector_overlays
|
||||||
|
from openslides.projector.projector import Overlay
|
||||||
|
from openslides.projector.api import get_active_slide, get_slide_from_sid
|
||||||
|
|
||||||
|
from .models import Speaker, Item
|
||||||
|
|
||||||
|
|
||||||
# TODO: Reinsert the datepicker scripts in the template
|
# TODO: Reinsert the datepicker scripts in the template
|
||||||
|
|
||||||
@ -48,3 +55,31 @@ def setup_agenda_config_page(sender, **kwargs):
|
|||||||
variables=(agenda_start_event_date_time,),
|
variables=(agenda_start_event_date_time,),
|
||||||
extra_context={'extra_stylefiles': extra_stylefiles,
|
extra_context={'extra_stylefiles': extra_stylefiles,
|
||||||
'extra_javascript': extra_javascript})
|
'extra_javascript': extra_javascript})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(projector_overlays, dispatch_uid="agenda_list_of_speakers")
|
||||||
|
def agenda_list_of_speakers(sender, **kwargs):
|
||||||
|
"""
|
||||||
|
Receiver for the list of speaker overlay.
|
||||||
|
"""
|
||||||
|
name = 'agenda_speaker'
|
||||||
|
|
||||||
|
def get_widget_html():
|
||||||
|
"""
|
||||||
|
Returns the the html-code to show in the overly-widget.
|
||||||
|
"""
|
||||||
|
return render_to_string('agenda/overlay_speaker_widget.html')
|
||||||
|
|
||||||
|
def get_projector_html():
|
||||||
|
"""
|
||||||
|
Returns an html-code to show on the projector.
|
||||||
|
"""
|
||||||
|
slide = get_slide_from_sid(get_active_slide(only_sid=True), element=True)
|
||||||
|
if not isinstance(slide, Item):
|
||||||
|
# Only show list of speakers on Agenda-Items
|
||||||
|
return None
|
||||||
|
speakers = Speaker.objects.filter(time=None, item=slide)[:5]
|
||||||
|
context = {'speakers': speakers}
|
||||||
|
return render_to_string('agenda/overlay_speaker_projector.html', context)
|
||||||
|
|
||||||
|
return Overlay(name, get_widget_html, get_projector_html)
|
||||||
|
@ -12,12 +12,18 @@
|
|||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from openslides.projector.api import register_slidemodel, register_slidefunc
|
||||||
|
|
||||||
|
from .models import Item
|
||||||
|
|
||||||
|
|
||||||
def agenda_show():
|
def agenda_show():
|
||||||
from openslides.agenda.models import Item
|
|
||||||
data = {}
|
data = {}
|
||||||
items = Item.objects.filter(parent=None, type__exact=Item.AGENDA_ITEM)
|
items = Item.objects.filter(parent=None, type__exact=Item.AGENDA_ITEM)
|
||||||
data['title'] = _("Agenda")
|
data['title'] = _("Agenda")
|
||||||
data['items'] = items
|
data['items'] = items
|
||||||
data['template'] = 'projector/AgendaSummary.html'
|
data['template'] = 'projector/AgendaSummary.html'
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
register_slidemodel(Item, control_template='agenda/control_item.html')
|
||||||
|
register_slidefunc('agenda', agenda_show, weight=-1, name=_('Agenda'))
|
||||||
|
@ -34,6 +34,10 @@ function hideClosedSlides(hide) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$('#speaker_list_changed_form').submit(function() {
|
||||||
|
$('#sort_order').val($('#list_of_speakers').sortable("toArray"));
|
||||||
|
});
|
||||||
|
|
||||||
$(function() {
|
$(function() {
|
||||||
// change participant status (on/off)
|
// change participant status (on/off)
|
||||||
$('.close_link').click(function(event) {
|
$('.close_link').click(function(event) {
|
||||||
@ -72,11 +76,18 @@ $(function() {
|
|||||||
$('#hide_closed_items').attr('checked', true);
|
$('#hide_closed_items').attr('checked', true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if ($.cookie('Slide.HideClosed') === null) {
|
|
||||||
$('#hide_closed_items').attr('checked', false);
|
// TODO: Fix this code and reactivate it again
|
||||||
$.cookie('Slide.HideClosed', 0);
|
//# if ($.cookie('Slide.HideClosed') === null) {
|
||||||
} else if ($.cookie('Slide.HideClosed') == 1) {
|
//# $('#hide_closed_items').attr('checked', false);
|
||||||
hideClosedSlides(true);
|
//# $.cookie('Slide.HideClosed', 0);
|
||||||
$('#hide_closed_items').attr('checked', true);
|
//# } else if ($.cookie('Slide.HideClosed') == 1) {
|
||||||
}
|
//# hideClosedSlides(true);
|
||||||
|
//# $('#hide_closed_items').attr('checked', true);
|
||||||
|
//# }
|
||||||
|
|
||||||
|
$('#list_of_speakers').sortable({axis: "y", containment: "parent", update: function(event, ui) {
|
||||||
|
$('#speaker_list_changed_form').show();
|
||||||
|
}});
|
||||||
|
$('#list_of_speakers').disableSelection();
|
||||||
});
|
});
|
||||||
|
@ -21,3 +21,17 @@ table#agendatime td {
|
|||||||
padding: 3px;
|
padding: 3px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#list_of_speakers li {
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#list_of_speakers {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#list_of_speakers span.ui-icon {
|
||||||
|
position: absolute;
|
||||||
|
margin-left: -15px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
|
#overlay_speaker_inner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-radius: 0.2em;
|
||||||
|
background-color: #cccccc;
|
||||||
|
opacity: 0.6;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
margin: 1em;
|
||||||
|
z-index:2;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="overlay_speaker_inner">
|
||||||
|
{% if speakers %}
|
||||||
|
<header>{% trans "List of speakers:" %}</header>
|
||||||
|
<ol>
|
||||||
|
{% for speaker in speakers %}
|
||||||
|
<li>{{ speaker }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% else %}
|
||||||
|
<i>{% trans 'The list of speakers is empty.' %}</i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
@ -0,0 +1,6 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load tags %}
|
||||||
|
|
||||||
|
<span>
|
||||||
|
{% trans "List of speakers" %}
|
||||||
|
</span>
|
6
openslides/agenda/templates/agenda/speaker_widget.html
Normal file
6
openslides/agenda/templates/agenda/speaker_widget.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load tags %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'agenda_current_list_of_speakers' %}">{% trans 'Put me on the current list of speakers' %}</a>
|
||||||
|
</div>
|
@ -1,21 +1,36 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load tags %}
|
||||||
|
{% load staticfiles %}
|
||||||
|
|
||||||
{% block title %}{{ block.super }} – {{ item.title }}{% endblock %}
|
{% block title %}{{ block.super }} – {{ item.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<link type="text/css" rel="stylesheet" media="all" href="{% static 'styles/agenda.css' %}" />
|
||||||
|
{% comment %} TODO: include stylesheet in our repo{% endcomment %}
|
||||||
|
<link rel="stylesheet" href="http://code.jquery.com/ui/1.10.2/themes/smoothness/jquery-ui.css" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascript %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% comment %} TODO: import the sortable-plugin in our custom jquery-file {% endcomment %}
|
||||||
|
<script src="http://code.jquery.com/ui/1.10.2/jquery-ui.js"></script>
|
||||||
|
<script src="{% static 'javascript/agenda.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>
|
<h1>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
<small class="pull-right">
|
<small class="pull-right">
|
||||||
<div class="btn-toolbar">
|
<div class="btn-toolbar">
|
||||||
<a href="{% url 'item_overview' %}" class="btn btn-mini"><i class="icon-chevron-left"></i> {% trans "Back to overview" %}</a>
|
<a href="{% url 'item_overview' %}" class="btn btn-mini"><i class="icon-chevron-left"></i> {% trans "Back to overview" %}</a>
|
||||||
|
{% if perms.agenda.can_manage_agenda %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a data-toggle="dropdown" href="#" class="btn btn-mini dropdown-toggle">
|
<a data-toggle="dropdown" href="#" class="btn btn-mini dropdown-toggle">
|
||||||
{% trans 'More actions' %}
|
{% trans 'More actions' %}
|
||||||
<span class="caret"></span>
|
<span class="caret"></span>
|
||||||
</a>
|
</a>
|
||||||
{% if perms.agenda.can_manage_agenda %}
|
|
||||||
<ul class="dropdown-menu pull-right">
|
<ul class="dropdown-menu pull-right">
|
||||||
<li><a href="{% url 'item_edit' item.id %}"><i class="icon-edit"></i> {% trans 'Edit item' %}</a></li>
|
<li><a href="{% url 'item_edit' item.id %}"><i class="icon-edit"></i> {% trans 'Edit item' %}</a></li>
|
||||||
<li><a href="{% url 'item_delete' item.id %}"><i class="icon-remove"></i> {% trans 'Delete item' %}</a></li>
|
<li><a href="{% url 'item_delete' item.id %}"><i class="icon-remove"></i> {% trans 'Delete item' %}</a></li>
|
||||||
@ -23,8 +38,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if perms.projector.can_manage_projector %}
|
{% if perms.projector.can_manage_projector %}
|
||||||
<a href="{% url 'projector_activate_slide' item.sid %}" class="activate_link btn btn-mini {% if item.active %}btn-primary{% endif %}" rel="tooltip" data-original-title="{% trans 'Show' %}">
|
<a href="{% url 'projector_activate_slide' item.sid %}"
|
||||||
<i class="icon icon-facetime-video {% if item.active %}icon-white{% endif %}"></i>
|
class="activate_link btn btn-mini {% if item.active and not show_list %}btn-primary{% endif %}"
|
||||||
|
rel="tooltip" data-original-title="{% trans 'Show' %}">
|
||||||
|
<i class="icon icon-facetime-video {% if item.active and not show_list %}icon-white{% endif %}"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -34,8 +51,130 @@
|
|||||||
|
|
||||||
{% if perms.agenda.can_manage_agenda %}
|
{% if perms.agenda.can_manage_agenda %}
|
||||||
{% if item.comment %}
|
{% if item.comment %}
|
||||||
<h2>{% trans "Comment" %}</h2>
|
<h3>{% trans "Comment" %}</h3>
|
||||||
<p>{{ item.comment|linebreaks }}</p>
|
<p>{{ item.comment|linebreaks }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# List of Speakers #}
|
||||||
|
<h3>{% trans "List of speakers" %} {% if item.speaker_list_closed %}<span class="label label-important">{% trans 'closed' %}</span>{% endif %}</h3>
|
||||||
|
<p>
|
||||||
|
{% if perms.agenda.can_manage_agenda %}
|
||||||
|
{% if item.speaker_list_closed %}
|
||||||
|
<a href="{% url 'agenda_speaker_reopen' item.pk %}" class="btn btn-mini btn-danger">{% trans 'Open list' %}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'agenda_speaker_close' item.pk %}" class="btn btn-mini btn-danger">{% trans 'Close list' %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.projector.can_manage_projector %}
|
||||||
|
<a href="{% url 'projector_activate_slide' item.sid 'show_list_of_speakers' %}"
|
||||||
|
class="activate_link btn btn-mini {% if item.active and show_list %}btn-primary{% endif %}"
|
||||||
|
rel="tooltip" data-original-title="{% trans 'Show list of speakers' %}">
|
||||||
|
<i class="icon icon-facetime-video {% if item.active and show_list %}icon-white{% endif %}"></i>
|
||||||
|
{% trans 'Show list' %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if old_speakers %}
|
||||||
|
<div class="well">
|
||||||
|
<b>{% trans "Last speakers:" %}</b>
|
||||||
|
<div class="btn-group" data-toggle="buttons-checkbox">
|
||||||
|
<button type="button" class="btn btn-mini" data-toggle="collapse" data-target="#all_speakers">
|
||||||
|
{% trans "Show all speakers" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div id="all_speakers" class="collapse out">
|
||||||
|
{% for speaker in old_speakers %}
|
||||||
|
{% if not forloop.last %}
|
||||||
|
<small>{{forloop.counter}}.
|
||||||
|
[{{ speaker.time }}h]
|
||||||
|
<a href="{% model_url speaker %}">{{ speaker }}</a>
|
||||||
|
{% if perms.agenda.can_manage_agenda %}
|
||||||
|
<a href="{% model_url speaker 'delete' %}" title="{% trans 'Delete' %}" class="btn btn-mini">
|
||||||
|
<i class="icon-remove"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</small><br>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if old_speakers %}
|
||||||
|
{% with last=old_speakers|last %}
|
||||||
|
<small>{{ old_speakers|length }}.
|
||||||
|
[{{ last.time }}h]
|
||||||
|
<a href="{% model_url last %}">{{ last }}</a>
|
||||||
|
{% if perms.agenda.can_manage_agenda %}
|
||||||
|
<a href="{% model_url last 'delete' %}" title="{% trans 'Delete' %}" class="btn btn-mini">
|
||||||
|
<i class="icon-remove"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if perms.agenda.can_manage_agenda %}
|
||||||
|
<form id="speaker_list_changed_form" action="{% url 'agenda_speaker_change_order' item.pk %}" method="post" style="display:none" class="alert alert-warning">{% csrf_token %}
|
||||||
|
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||||
|
<p>{% trans "Do you want to save the changed order of speakers?" %}</p>
|
||||||
|
<input id="sort_order" name="sort_order" type="hidden"></hidden>
|
||||||
|
<p>
|
||||||
|
<button class="btn" type="submit">{% trans 'Yes' %}</button>
|
||||||
|
<a href="{% url 'item_view' item.pk %}" class="btn">{% trans 'No' %}</a>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="well">
|
||||||
|
<b>{% trans "Next speakers:" %}</b>
|
||||||
|
<ul {% if perms.agenda.can_manage_agenda %}id="list_of_speakers"{% endif %}>
|
||||||
|
{% for speaker in speakers %}
|
||||||
|
<li id="speaker_{{ speaker.pk }}">
|
||||||
|
<span {% if perms.agenda.can_manage_agenda %}class="ui-icon ui-icon-arrowthick-2-n-s"{% endif %}></span>
|
||||||
|
<span>{{ forloop.counter }}.</span>
|
||||||
|
<a href="{% model_url speaker %}">{{ speaker }}</a>
|
||||||
|
{% if perms.agenda.can_manage_agenda %}
|
||||||
|
<a href="{% url 'agenda_speaker_speak' item.pk speaker.person.person_id %}" class="btn btn-mini"><i class="icon-bell"></i> Finished speech</a>
|
||||||
|
<a href="{% model_url speaker 'delete' %}" title="{% trans 'Delete' %}" class="btn btn-mini">
|
||||||
|
<i class="icon-remove"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<i>{% trans "The list of speakers is empty." %}</i>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% if is_speaker %}
|
||||||
|
<a href="{% url 'agenda_speaker_delete' object.id %}" class="btn">{% trans "Remove me from the list" %}</a>
|
||||||
|
{% elif not object.speaker_list_closed %}
|
||||||
|
<a href="{% url 'agenda_speaker_append' object.id %}" class="btn">{% trans "Put me on the list" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if perms.can_manage_agenda %}
|
||||||
|
<form action="" method="post">{% csrf_token %}
|
||||||
|
{% for field in form %}
|
||||||
|
<label>{{ field.label }}:</label>
|
||||||
|
<div class="control-group input-append {% if field.errors %}error{% endif %}">
|
||||||
|
{{ field }}
|
||||||
|
<button class="btn btn-primary" type="submit" title="{% trans 'Apply' %}"><i class="icon-ok icon-white"></i></button>
|
||||||
|
{% if perms.participant.can_see_participant and perms.participant.can_manage_participant %}
|
||||||
|
<a href="{% url 'user_new' %}" class="btn" title="{% trans 'Add new participant' %}"><i class="icon-add-user"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.errors %}
|
||||||
|
<span class="help-inline">{{ field.errors }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base-projector.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{{ block.super }} - {{ item }}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<style type="text/css">
|
||||||
|
#list_of_speakers li
|
||||||
|
{
|
||||||
|
font-size: 130%;
|
||||||
|
line-height: 160%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scrollcontent %}
|
||||||
|
{% if speakers %}
|
||||||
|
<ol id="list_of_speakers">
|
||||||
|
{% for speaker in speakers %}
|
||||||
|
<li>{{ speaker }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% else %}
|
||||||
|
{% trans 'The list of speakers is empty' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
@ -12,8 +12,9 @@
|
|||||||
|
|
||||||
from django.conf.urls import url, patterns
|
from django.conf.urls import url, patterns
|
||||||
from openslides.agenda.views import (
|
from openslides.agenda.views import (
|
||||||
Overview, View, SetClosed, ItemUpdate,
|
Overview, AgendaItemView, SetClosed, ItemUpdate, SpeakerSpeakView,
|
||||||
ItemCreate, ItemDelete, AgendaPDF)
|
ItemCreate, ItemDelete, AgendaPDF, SpeakerAppendView, SpeakerDeleteView,
|
||||||
|
SpeakerListCloseView, SpeakerChangeOrderView, CurrentListOfSpeakersView)
|
||||||
|
|
||||||
urlpatterns = patterns(
|
urlpatterns = patterns(
|
||||||
'',
|
'',
|
||||||
@ -23,7 +24,7 @@ urlpatterns = patterns(
|
|||||||
),
|
),
|
||||||
|
|
||||||
url(r'^(?P<pk>\d+)/$',
|
url(r'^(?P<pk>\d+)/$',
|
||||||
View.as_view(),
|
AgendaItemView.as_view(),
|
||||||
name='item_view',
|
name='item_view',
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -58,4 +59,45 @@ urlpatterns = patterns(
|
|||||||
AgendaPDF.as_view(),
|
AgendaPDF.as_view(),
|
||||||
name='print_agenda',
|
name='print_agenda',
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Speaker List
|
||||||
|
url(r'^(?P<pk>\d+)/speaker/$',
|
||||||
|
SpeakerAppendView.as_view(),
|
||||||
|
name='agenda_speaker_append',
|
||||||
|
),
|
||||||
|
|
||||||
|
url(r'^(?P<pk>\d+)/speaker/close/$',
|
||||||
|
SpeakerListCloseView.as_view(),
|
||||||
|
name='agenda_speaker_close',
|
||||||
|
),
|
||||||
|
|
||||||
|
url(r'^(?P<pk>\d+)/speaker/reopen/$',
|
||||||
|
SpeakerListCloseView.as_view(reopen=True),
|
||||||
|
name='agenda_speaker_reopen',
|
||||||
|
),
|
||||||
|
|
||||||
|
url(r'^(?P<pk>\d+)/speaker/del/$',
|
||||||
|
SpeakerDeleteView.as_view(),
|
||||||
|
name='agenda_speaker_delete',
|
||||||
|
),
|
||||||
|
|
||||||
|
url(r'^(?P<pk>\d+)/speaker/(?P<speaker>\d+)/del/$',
|
||||||
|
SpeakerDeleteView.as_view(),
|
||||||
|
name='agenda_speaker_delete',
|
||||||
|
),
|
||||||
|
|
||||||
|
url(r'^(?P<pk>\d+)/speaker/(?P<person_id>[^/]+)/speak/$',
|
||||||
|
SpeakerSpeakView.as_view(),
|
||||||
|
name='agenda_speaker_speak',
|
||||||
|
),
|
||||||
|
|
||||||
|
url(r'^(?P<pk>\d+)/speaker/change_order/$',
|
||||||
|
SpeakerChangeOrderView.as_view(),
|
||||||
|
name='agenda_speaker_change_order',
|
||||||
|
),
|
||||||
|
|
||||||
|
url(r'^list_of_speakers/$',
|
||||||
|
CurrentListOfSpeakersView.as_view(),
|
||||||
|
name='agenda_current_list_of_speakers',
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
:copyright: 2011, 2012 by the OpenSlides team, see AUTHORS.
|
:copyright: 2011, 2012 by the OpenSlides team, see AUTHORS.
|
||||||
:license: GNU GPL, see LICENSE for more details.
|
:license: GNU GPL, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
# TODO: Rename all views and template names
|
||||||
|
|
||||||
from reportlab.platypus import Paragraph
|
from reportlab.platypus import Paragraph
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -22,15 +23,16 @@ from django.views.generic.detail import SingleObjectMixin
|
|||||||
|
|
||||||
from openslides.config.api import config
|
from openslides.config.api import config
|
||||||
from openslides.utils.pdf import stylesheet
|
from openslides.utils.pdf import stylesheet
|
||||||
|
from openslides.utils.exceptions import OpenSlidesError
|
||||||
from openslides.utils.views import (
|
from openslides.utils.views import (
|
||||||
TemplateView, RedirectView, UpdateView, CreateView, DeleteView, PDFView,
|
TemplateView, RedirectView, UpdateView, CreateView, DeleteView, PDFView,
|
||||||
DetailView, FormView)
|
DetailView, FormView, SingleObjectMixin)
|
||||||
from openslides.utils.template import Tab
|
from openslides.utils.template import Tab
|
||||||
from openslides.utils.utils import html_strong
|
from openslides.utils.utils import html_strong
|
||||||
from openslides.projector.api import get_active_slide
|
from openslides.projector.api import get_active_slide, get_slide_from_sid
|
||||||
from openslides.projector.projector import Widget, SLIDE
|
from openslides.projector.projector import Widget, SLIDE
|
||||||
from .models import Item
|
from .models import Item, Speaker
|
||||||
from .forms import ItemOrderForm, ItemForm
|
from .forms import ItemOrderForm, ItemForm, AppendSpeakerForm
|
||||||
|
|
||||||
|
|
||||||
class Overview(TemplateView):
|
class Overview(TemplateView):
|
||||||
@ -112,14 +114,40 @@ class Overview(TemplateView):
|
|||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
class View(DetailView):
|
class AgendaItemView(SingleObjectMixin, FormView):
|
||||||
"""
|
"""
|
||||||
Show an agenda item.
|
Show an agenda item.
|
||||||
"""
|
"""
|
||||||
|
# TODO: use 'SingleObjectTemplateResponseMixin' to choose the right template name
|
||||||
permission_required = 'agenda.can_see_agenda'
|
permission_required = 'agenda.can_see_agenda'
|
||||||
template_name = 'agenda/view.html'
|
template_name = 'agenda/view.html'
|
||||||
model = Item
|
model = Item
|
||||||
context_object_name = 'item'
|
context_object_name = 'item'
|
||||||
|
form_class = AppendSpeakerForm
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
speakers = Speaker.objects.filter(time=None, item=self.object.pk).order_by('weight')
|
||||||
|
old_speakers = list(Speaker.objects.filter(item=self.object.pk)
|
||||||
|
.exclude(time=None).order_by('time'))
|
||||||
|
kwargs.update({
|
||||||
|
'object': self.object,
|
||||||
|
'speakers': speakers,
|
||||||
|
'old_speakers': old_speakers,
|
||||||
|
'is_speaker': Speaker.objects.filter(
|
||||||
|
time=None, person=self.request.user, item=self.object).exists(),
|
||||||
|
'show_list': config['presentation_argument'] == 'show_list_of_speakers',
|
||||||
|
})
|
||||||
|
return super(AgendaItemView, self).get_context_data(**kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
Speaker.objects.add(person=form.cleaned_data['speaker'], item=self.get_object())
|
||||||
|
return self.render_to_response(self.get_context_data(form=form))
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super(AgendaItemView, self).get_form_kwargs()
|
||||||
|
kwargs['item'] = self.get_object()
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class SetClosed(RedirectView, SingleObjectMixin):
|
class SetClosed(RedirectView, SingleObjectMixin):
|
||||||
@ -149,6 +177,9 @@ class SetClosed(RedirectView, SingleObjectMixin):
|
|||||||
self.object.set_closed(closed)
|
self.object.set_closed(closed)
|
||||||
return super(SetClosed, self).pre_redirect(request, *args, **kwargs)
|
return super(SetClosed, self).pre_redirect(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_url_name_args(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class ItemUpdate(UpdateView):
|
class ItemUpdate(UpdateView):
|
||||||
"""
|
"""
|
||||||
@ -224,6 +255,209 @@ class AgendaPDF(PDFView):
|
|||||||
story.append(Paragraph(item.get_title(), stylesheet['Item']))
|
story.append(Paragraph(item.get_title(), stylesheet['Item']))
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerAppendView(SingleObjectMixin, RedirectView):
|
||||||
|
"""
|
||||||
|
Set the request.user to the speaker list.
|
||||||
|
"""
|
||||||
|
permission_required = 'agenda.can_be_speaker'
|
||||||
|
url_name = 'item_view'
|
||||||
|
model = Item
|
||||||
|
|
||||||
|
def pre_redirect(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
if self.object.speaker_list_closed:
|
||||||
|
messages.error(request, _('List of speakers is closed.'))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
Speaker.objects.add(item=self.object, person=request.user)
|
||||||
|
except OpenSlidesError, e:
|
||||||
|
messages.error(request, e)
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerDeleteView(DeleteView):
|
||||||
|
"""
|
||||||
|
Delete the request.user or a specific user from the speaker list.
|
||||||
|
"""
|
||||||
|
success_url_name = 'item_view'
|
||||||
|
question_url_name = 'item_view'
|
||||||
|
|
||||||
|
def has_permission(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Check the permission to delete a speaker.
|
||||||
|
"""
|
||||||
|
if 'speaker' in kwargs:
|
||||||
|
return request.user.has_perm('agenda.can_manage_agenda')
|
||||||
|
else:
|
||||||
|
# Any person how is on the list of speakers can delete him self from the list
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return super(SpeakerDeleteView, self).get(*args, **kwargs)
|
||||||
|
except Speaker.DoesNotExist:
|
||||||
|
messages.error(self.request, _('You are not on the list of speakers.'))
|
||||||
|
return super(RedirectView, self).get(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""
|
||||||
|
Returns the speaker object.
|
||||||
|
|
||||||
|
If 'speaker' is in kwargs, this speaker object is returnd. Else, a speaker
|
||||||
|
object with the request.user as speaker.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Speaker.objects.get(pk=self.kwargs['speaker'])
|
||||||
|
except KeyError:
|
||||||
|
return Speaker.objects.filter(
|
||||||
|
item=self.kwargs['pk'], person=self.request.user).exclude(weight=None).get()
|
||||||
|
|
||||||
|
def get_url_name_args(self):
|
||||||
|
return [self.kwargs['pk']]
|
||||||
|
|
||||||
|
def get_question(self):
|
||||||
|
if 'speaker' in self.kwargs:
|
||||||
|
return super(SpeakerDeleteView, self).get_question()
|
||||||
|
else:
|
||||||
|
return _('Do you really want to remove yourself from the list of speakers?')
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerSpeakView(SingleObjectMixin, RedirectView):
|
||||||
|
"""
|
||||||
|
Mark the speaking person.
|
||||||
|
"""
|
||||||
|
permission_required = 'agenda.can_manage_agenda'
|
||||||
|
url_name = 'item_view'
|
||||||
|
model = Item
|
||||||
|
|
||||||
|
def pre_redirect(self, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
try:
|
||||||
|
speaker = Speaker.objects.filter(
|
||||||
|
person=kwargs['person_id'],
|
||||||
|
item=self.object.pk).exclude(
|
||||||
|
weight=None).get()
|
||||||
|
except Speaker.DoesNotExist:
|
||||||
|
messages.error(self.request, _('Person %s is not on the list of item %s.'
|
||||||
|
% (kwargs['person_id'], self.object)))
|
||||||
|
else:
|
||||||
|
speaker.speak()
|
||||||
|
|
||||||
|
def get_url_name_args(self):
|
||||||
|
return [self.object.pk]
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerListCloseView(SingleObjectMixin, RedirectView):
|
||||||
|
"""
|
||||||
|
View to close and reopen a list of speakers.
|
||||||
|
"""
|
||||||
|
permission_required = 'agenda.can_manage_agenda'
|
||||||
|
model = Item
|
||||||
|
reopen = False
|
||||||
|
url_name = 'item_view'
|
||||||
|
|
||||||
|
def pre_redirect(self, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
self.object.speaker_list_closed = not self.reopen
|
||||||
|
self.object.save()
|
||||||
|
|
||||||
|
def get_url_name_args(self):
|
||||||
|
return [self.object.pk]
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerChangeOrderView(SingleObjectMixin, RedirectView):
|
||||||
|
"""
|
||||||
|
Change the order of the speakers.
|
||||||
|
|
||||||
|
Has to be called as post-request with the new order of the speaker ids.
|
||||||
|
"""
|
||||||
|
permission_required = 'agenda.can_manage_agenda'
|
||||||
|
model = Item
|
||||||
|
url_name = 'item_view'
|
||||||
|
|
||||||
|
def pre_redirect(self, args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
|
||||||
|
@transaction.commit_manually
|
||||||
|
def pre_post_redirect(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Reorder the list of speaker.
|
||||||
|
|
||||||
|
Take the string 'sort_order' from the post-data, and use this order.
|
||||||
|
"""
|
||||||
|
self.object = self.get_object()
|
||||||
|
transaction.commit()
|
||||||
|
for (counter, speaker) in enumerate(self.request.POST['sort_order'].split(',')):
|
||||||
|
try:
|
||||||
|
speaker_pk = int(speaker.split('_')[1])
|
||||||
|
except IndexError:
|
||||||
|
transaction.rollback()
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
speaker = Speaker.objects.filter(item=self.object).get(pk=speaker_pk)
|
||||||
|
except:
|
||||||
|
transaction.rollback()
|
||||||
|
break
|
||||||
|
speaker.weight = counter + 1
|
||||||
|
speaker.save()
|
||||||
|
else:
|
||||||
|
transaction.commit()
|
||||||
|
return None
|
||||||
|
messages.error(request, _('Could not change order. Invalid data.'))
|
||||||
|
|
||||||
|
def get_url_name_args(self):
|
||||||
|
return [self.object.pk]
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentListOfSpeakersView(RedirectView):
|
||||||
|
"""
|
||||||
|
Redirect to the current list of speakers and set the request.user on it.
|
||||||
|
"""
|
||||||
|
def get_item(self):
|
||||||
|
"""
|
||||||
|
Returns the current Item, or None, if the current Slide is not an Agenda Item.
|
||||||
|
"""
|
||||||
|
slide = get_slide_from_sid(get_active_slide(only_sid=True), element=True)
|
||||||
|
if not isinstance(slide, Item):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return slide
|
||||||
|
|
||||||
|
def get_redirect_url(self):
|
||||||
|
"""
|
||||||
|
Returns the URL to the item_view if:
|
||||||
|
|
||||||
|
* the current slide is an item and
|
||||||
|
* the user has the permission to see the item
|
||||||
|
|
||||||
|
in other case, it returns the URL to the dashboard.
|
||||||
|
|
||||||
|
This method also add the request.user to the list of speakers, if he
|
||||||
|
has the right permissions.
|
||||||
|
"""
|
||||||
|
item = self.get_item()
|
||||||
|
request = self.request
|
||||||
|
if item is None:
|
||||||
|
messages.error(request, _(
|
||||||
|
'There is no list of speakers for the current slide. '
|
||||||
|
'Please choose your agenda item manually from the agenda.'))
|
||||||
|
return reverse('dashboard')
|
||||||
|
|
||||||
|
if self.request.user.has_perm('agenda.can_be_speaker'):
|
||||||
|
try:
|
||||||
|
Speaker.objects.add(self.request.user, item)
|
||||||
|
except OpenSlidesError:
|
||||||
|
messages.error(request, _('You are already on the list of speakers.'))
|
||||||
|
else:
|
||||||
|
messages.success(request, _('You are now on the list of speakers.'))
|
||||||
|
else:
|
||||||
|
messages.error(request, _('You can not put yourself on the list of speakers.'))
|
||||||
|
|
||||||
|
if not self.request.user.has_perm('agenda.can_see_agenda'):
|
||||||
|
return reverse('dashboard')
|
||||||
|
else:
|
||||||
|
return reverse('item_view', args=[item.pk])
|
||||||
|
|
||||||
|
|
||||||
def register_tab(request):
|
def register_tab(request):
|
||||||
"""
|
"""
|
||||||
Registers the agenda tab.
|
Registers the agenda tab.
|
||||||
@ -242,11 +476,19 @@ def get_widgets(request):
|
|||||||
"""
|
"""
|
||||||
Returns the agenda widget for the projector tab.
|
Returns the agenda widget for the projector tab.
|
||||||
"""
|
"""
|
||||||
return [Widget(
|
return [
|
||||||
|
Widget(
|
||||||
name='agenda',
|
name='agenda',
|
||||||
display_name=_('Agenda'),
|
display_name=_('Agenda'),
|
||||||
template='agenda/widget.html',
|
template='agenda/widget.html',
|
||||||
context={
|
context={
|
||||||
'agenda': SLIDE['agenda'],
|
'agenda': SLIDE['agenda'],
|
||||||
'items': Item.objects.all()},
|
'items': Item.objects.all()},
|
||||||
permission_required='projector.can_manage_projector')]
|
permission_required='projector.can_manage_projector'),
|
||||||
|
|
||||||
|
Widget(
|
||||||
|
name='append_to_list_of_speakers',
|
||||||
|
display_name=_('To the current list of speakers'),
|
||||||
|
template='agenda/speaker_widget.html',
|
||||||
|
context={},
|
||||||
|
permission_required='agenda.can_be_speaker')]
|
||||||
|
@ -115,8 +115,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<p>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -86,16 +86,16 @@ class User(PersonMixin, Person, SlideMixin, DjangoUser):
|
|||||||
return self.last_name.lower()
|
return self.last_name.lower()
|
||||||
|
|
||||||
@models.permalink
|
@models.permalink
|
||||||
def get_absolute_url(self, link='view'):
|
def get_absolute_url(self, link='detail'):
|
||||||
"""
|
"""
|
||||||
Return the URL to this user.
|
Return the URL to this user.
|
||||||
|
|
||||||
link can be:
|
link can be:
|
||||||
* view
|
* detail
|
||||||
* edit
|
* edit
|
||||||
* delete
|
* delete
|
||||||
"""
|
"""
|
||||||
if link == 'view':
|
if link == 'detail' or link == 'view':
|
||||||
return ('user_view', [str(self.id)])
|
return ('user_view', [str(self.id)])
|
||||||
if link == 'edit':
|
if link == 'edit':
|
||||||
return ('user_edit', [str(self.id)])
|
return ('user_edit', [str(self.id)])
|
||||||
|
@ -77,8 +77,10 @@ def create_builtin_groups(sender, **kwargs):
|
|||||||
perm_2 = Permission.objects.get(content_type=ct_projector, codename='can_see_dashboard')
|
perm_2 = Permission.objects.get(content_type=ct_projector, codename='can_see_dashboard')
|
||||||
|
|
||||||
ct_agenda = ContentType.objects.get(app_label='agenda', model='item')
|
ct_agenda = ContentType.objects.get(app_label='agenda', model='item')
|
||||||
|
ct_speaker = ContentType.objects.get(app_label='agenda', model='speaker')
|
||||||
perm_3 = Permission.objects.get(content_type=ct_agenda, codename='can_see_agenda')
|
perm_3 = Permission.objects.get(content_type=ct_agenda, codename='can_see_agenda')
|
||||||
perm_3a = Permission.objects.get(content_type=ct_agenda, codename='can_see_orga_items')
|
perm_3a = Permission.objects.get(content_type=ct_agenda, codename='can_see_orga_items')
|
||||||
|
can_speak = Permission.objects.get(content_type=ct_speaker, codename='can_be_speaker')
|
||||||
|
|
||||||
ct_motion = ContentType.objects.get(app_label='motion', model='motion')
|
ct_motion = ContentType.objects.get(app_label='motion', model='motion')
|
||||||
perm_4 = Permission.objects.get(content_type=ct_motion, codename='can_see_motion')
|
perm_4 = Permission.objects.get(content_type=ct_motion, codename='can_see_motion')
|
||||||
@ -95,7 +97,7 @@ def create_builtin_groups(sender, **kwargs):
|
|||||||
group_anonymous = Group.objects.create(name=ugettext_noop('Anonymous'), pk=1)
|
group_anonymous = Group.objects.create(name=ugettext_noop('Anonymous'), pk=1)
|
||||||
group_anonymous.permissions.add(perm_1, perm_2, perm_3, perm_3a, perm_4, perm_5, perm_6, perm_6a)
|
group_anonymous.permissions.add(perm_1, perm_2, perm_3, perm_3a, perm_4, perm_5, perm_6, perm_6a)
|
||||||
group_registered = Group.objects.create(name=ugettext_noop('Registered'), pk=2)
|
group_registered = Group.objects.create(name=ugettext_noop('Registered'), pk=2)
|
||||||
group_registered.permissions.add(perm_1, perm_2, perm_3, perm_3a, perm_4, perm_5, perm_6, perm_6a)
|
group_registered.permissions.add(perm_1, perm_2, perm_3, perm_3a, perm_4, perm_5, perm_6, perm_6a, can_speak)
|
||||||
|
|
||||||
# Delegates
|
# Delegates
|
||||||
perm_7 = Permission.objects.get(content_type=ct_motion, codename='can_create_motion')
|
perm_7 = Permission.objects.get(content_type=ct_motion, codename='can_create_motion')
|
||||||
|
@ -169,4 +169,4 @@ class Overlay(object):
|
|||||||
return self.name in config['projector_active_overlays']
|
return self.name in config['projector_active_overlays']
|
||||||
|
|
||||||
def show_on_projector(self):
|
def show_on_projector(self):
|
||||||
return self.is_active and self.get_projector_html() is not None
|
return self.is_active() and self.get_projector_html() is not None
|
||||||
|
@ -84,7 +84,7 @@ def setup_projector_config_variables(sender, **kwargs):
|
|||||||
@receiver(projector_overlays, dispatch_uid="projector_countdown")
|
@receiver(projector_overlays, dispatch_uid="projector_countdown")
|
||||||
def countdown(sender, **kwargs):
|
def countdown(sender, **kwargs):
|
||||||
"""
|
"""
|
||||||
Reveiver for the countdown.
|
Receiver for the countdown.
|
||||||
"""
|
"""
|
||||||
name = 'projector_countdown'
|
name = 'projector_countdown'
|
||||||
request = kwargs.get('request', None)
|
request = kwargs.get('request', None)
|
||||||
|
@ -147,7 +147,6 @@ body{
|
|||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Table */
|
/* Table */
|
||||||
table {
|
table {
|
||||||
border-collapse:collapse;
|
border-collapse:collapse;
|
||||||
|
@ -16,4 +16,4 @@
|
|||||||
|
|
||||||
<div id="overlay_message_inner">
|
<div id="overlay_message_inner">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</span>
|
</div>
|
||||||
|
@ -23,7 +23,7 @@ class Person(object):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError('Any person object needs a person_id')
|
raise NotImplementedError('Any person object needs a person_id')
|
||||||
|
|
||||||
def __repr__(self):
|
def __unicode__(self):
|
||||||
"""
|
"""
|
||||||
Return a string for this person.
|
Return a string for this person.
|
||||||
"""
|
"""
|
||||||
|
@ -42,6 +42,8 @@ class PersonField(models.fields.Field):
|
|||||||
"""
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
elif isinstance(value, basestring):
|
||||||
|
return value
|
||||||
else:
|
else:
|
||||||
return value.person_id
|
return value.person_id
|
||||||
|
|
||||||
|
@ -149,15 +149,26 @@ class QuestionMixin(object):
|
|||||||
|
|
||||||
def get_redirect_url(self, **kwargs):
|
def get_redirect_url(self, **kwargs):
|
||||||
if self.request.method == 'GET':
|
if self.request.method == 'GET':
|
||||||
return reverse(self.question_url_name, args=self.get_url_name_args())
|
return reverse(self.question_url_name, args=self.get_question_url_name_args())
|
||||||
else:
|
else:
|
||||||
return reverse(self.success_url_name, args=self.get_url_name_args())
|
return reverse(self.success_url_name, args=self.get_success_url_name_args())
|
||||||
|
|
||||||
|
def get_question_url_name_args(self):
|
||||||
|
return self.get_url_name_args()
|
||||||
|
|
||||||
|
def get_success_url_name_args(self):
|
||||||
|
return self.get_url_name_args()
|
||||||
|
|
||||||
def get_url_name_args(self):
|
def get_url_name_args(self):
|
||||||
|
try:
|
||||||
|
return [self.object.pk]
|
||||||
|
except AttributeError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def pre_redirect(self, request, *args, **kwargs):
|
def pre_redirect(self, request, *args, **kwargs):
|
||||||
# Prints the question in a GET request
|
"""
|
||||||
|
Prints the question in a GET request.
|
||||||
|
"""
|
||||||
self.confirm_form()
|
self.confirm_form()
|
||||||
|
|
||||||
def get_question(self):
|
def get_question(self):
|
||||||
@ -251,6 +262,9 @@ class RedirectView(PermissionMixin, AjaxMixin, _RedirectView):
|
|||||||
return super(RedirectView, self).get_redirect_url(**kwargs)
|
return super(RedirectView, self).get_redirect_url(**kwargs)
|
||||||
|
|
||||||
def get_url_name_args(self):
|
def get_url_name_args(self):
|
||||||
|
try:
|
||||||
|
return [self.object.pk]
|
||||||
|
except AttributeError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@ -319,6 +333,9 @@ class DeleteView(SingleObjectMixin, QuestionMixin, RedirectView):
|
|||||||
def get_success_message(self):
|
def get_success_message(self):
|
||||||
return _('%s was successfully deleted.') % html_strong(self.object)
|
return _('%s was successfully deleted.') % html_strong(self.object)
|
||||||
|
|
||||||
|
def get_url_name_args(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class DetailView(PermissionMixin, ExtraContextMixin, _DetailView):
|
class DetailView(PermissionMixin, ExtraContextMixin, _DetailView):
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
175
tests/agenda/test_list_of_speakers.py
Normal file
175
tests/agenda/test_list_of_speakers.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Unit test for the list of speakers
|
||||||
|
|
||||||
|
:copyright: 2011–2013 by OpenSlides team, see AUTHORS.
|
||||||
|
:license: GNU GPL, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test.client import Client
|
||||||
|
|
||||||
|
from openslides.utils.exceptions import OpenSlidesError
|
||||||
|
from openslides.utils.test import TestCase
|
||||||
|
from openslides.participant.models import User
|
||||||
|
from openslides.agenda.models import Item, Speaker
|
||||||
|
|
||||||
|
|
||||||
|
class ListOfSpeakerModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.item1 = Item.objects.create(title='item1')
|
||||||
|
self.item2 = Item.objects.create(title='item2')
|
||||||
|
self.speaker1 = User.objects.create(username='user1')
|
||||||
|
self.speaker2 = User.objects.create(username='user2')
|
||||||
|
|
||||||
|
def test_append_speaker(self):
|
||||||
|
# Append speaker1 to the list of item1
|
||||||
|
speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1)
|
||||||
|
self.assertTrue(Speaker.objects.filter(person=self.speaker1, item=self.item1).exists())
|
||||||
|
|
||||||
|
# Append speaker1 to the list of item2
|
||||||
|
speaker1_item2 = Speaker.objects.add(self.speaker1, self.item2)
|
||||||
|
self.assertTrue(Speaker.objects.filter(person=self.speaker1, item=self.item2).exists())
|
||||||
|
|
||||||
|
# Append speaker2 to the list of item1
|
||||||
|
speaker2_item1 = Speaker.objects.add(self.speaker2, self.item1)
|
||||||
|
self.assertTrue(Speaker.objects.filter(person=self.speaker2, item=self.item1).exists())
|
||||||
|
|
||||||
|
# Try to append speaker 1 again to the list of item1
|
||||||
|
with self.assertRaises(OpenSlidesError):
|
||||||
|
Speaker.objects.add(self.speaker1, self.item1)
|
||||||
|
|
||||||
|
# Check time and weight
|
||||||
|
for object in (speaker1_item1, speaker2_item1, speaker1_item2):
|
||||||
|
self.assertIsNone(object.time)
|
||||||
|
self.assertEqual(speaker1_item1.weight, 1)
|
||||||
|
self.assertEqual(speaker1_item2.weight, 1)
|
||||||
|
self.assertEqual(speaker2_item1.weight, 2)
|
||||||
|
|
||||||
|
def test_open_close_list_of_speaker(self):
|
||||||
|
self.assertFalse(Item.objects.get(pk=self.item1.pk).speaker_list_closed)
|
||||||
|
self.item1.speaker_list_closed = True
|
||||||
|
self.item1.save()
|
||||||
|
self.assertTrue(Item.objects.get(pk=self.item1.pk).speaker_list_closed)
|
||||||
|
|
||||||
|
def test_speak(self):
|
||||||
|
speaker1_item1 = Speaker.objects.add(self.speaker1, self.item1)
|
||||||
|
|
||||||
|
self.assertIsNone(speaker1_item1.time)
|
||||||
|
speaker1_item1.speak()
|
||||||
|
self.assertIsNotNone(Speaker.objects.get(pk=speaker1_item1.pk).time)
|
||||||
|
self.assertIsNone(Speaker.objects.get(pk=speaker1_item1.pk).weight)
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Admin
|
||||||
|
self.admin = User.objects.create_superuser('admin', 'admin@admin.admin', 'admin')
|
||||||
|
self.admin_client = Client()
|
||||||
|
self.admin_client.login(username='admin', password='admin')
|
||||||
|
|
||||||
|
# Speaker1
|
||||||
|
self.speaker1 = User.objects.create_user('speaker1', 'speaker1@user.user', 'speaker')
|
||||||
|
self.speaker1_client = Client()
|
||||||
|
self.speaker1_client.login(username='speaker1', password='speaker')
|
||||||
|
|
||||||
|
# Speaker2
|
||||||
|
self.speaker2 = User.objects.create_user('speaker2', 'speaker2@user.user', 'speaker')
|
||||||
|
self.speaker2_client = Client()
|
||||||
|
self.speaker2_client.login(username='speaker2', password='speaker')
|
||||||
|
|
||||||
|
# Items
|
||||||
|
self.item1 = Item.objects.create(title='item1')
|
||||||
|
self.item2 = Item.objects.create(title='item2')
|
||||||
|
|
||||||
|
def check_url(self, url, test_client, response_cose):
|
||||||
|
response = test_client.get(url)
|
||||||
|
self.assertEqual(response.status_code, response_cose)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def assertMessage(self, response, message):
|
||||||
|
self.assertTrue(message in response.cookies['messages'].value,
|
||||||
|
'"%s" is not a message of the response. (But: %s)'
|
||||||
|
% (message, response.cookies['messages'].value))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpeakerAppendView(SpeakerViewTestCase):
|
||||||
|
def test_get(self):
|
||||||
|
self.assertFalse(Speaker.objects.filter(person=self.speaker1, item=self.item1).exists())
|
||||||
|
self.assertEqual(Speaker.objects.filter(item=self.item1).count(), 0)
|
||||||
|
|
||||||
|
# Set speaker1 to item1
|
||||||
|
self.check_url('/agenda/1/speaker/', self.speaker1_client, 302)
|
||||||
|
self.assertTrue(Speaker.objects.filter(person=self.speaker1, item=self.item1).exists())
|
||||||
|
self.assertEqual(Speaker.objects.filter(item=self.item1).count(), 1)
|
||||||
|
|
||||||
|
# Try to set speaker 1 to item 1 again
|
||||||
|
response = self.check_url('/agenda/1/speaker/', self.speaker1_client, 302)
|
||||||
|
self.assertEqual(Speaker.objects.filter(item=self.item1).count(), 1)
|
||||||
|
self.assertMessage(response, 'speaker1 is already on the list of speakers of item 1.')
|
||||||
|
|
||||||
|
def test_closed_list(self):
|
||||||
|
self.item1.speaker_list_closed = True
|
||||||
|
self.item1.save()
|
||||||
|
|
||||||
|
response = self.check_url('/agenda/1/speaker/', self.speaker1_client, 302)
|
||||||
|
self.assertEqual(Speaker.objects.filter(item=self.item1).count(), 0)
|
||||||
|
self.assertMessage(response, 'List of speakers is closed.')
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgendaItemView(SpeakerViewTestCase):
|
||||||
|
def test_post(self):
|
||||||
|
# Set speaker1 to item1
|
||||||
|
response = self.admin_client.post(
|
||||||
|
'/agenda/1/', {'speaker': self.speaker1.person_id})
|
||||||
|
self.assertTrue(Speaker.objects.filter(person=self.speaker1, item=self.item1).exists())
|
||||||
|
|
||||||
|
# Try it again
|
||||||
|
response = self.admin_client.post(
|
||||||
|
'/agenda/1/', {'speaker': self.speaker1.person_id})
|
||||||
|
self.assertFormError(response, 'form', 'speaker', 'speaker1 is already on the list of speakers.')
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpeakerDeleteView(SpeakerViewTestCase):
|
||||||
|
def test_get(self):
|
||||||
|
self.check_url('/agenda/1/speaker/del/', self.speaker1_client, 302)
|
||||||
|
|
||||||
|
def test_post_as_admin(self):
|
||||||
|
speaker = Speaker.objects.add(self.speaker1, self.item1)
|
||||||
|
|
||||||
|
response = self.admin_client.post(
|
||||||
|
'/agenda/1/speaker/%d/del/' % speaker.pk, {'yes': 'yes'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(Speaker.objects.filter(person=self.speaker1, item=self.item1).exists())
|
||||||
|
|
||||||
|
def test_post_as_user(self):
|
||||||
|
speaker = Speaker.objects.add(self.speaker1, self.item1)
|
||||||
|
|
||||||
|
response = self.speaker1_client.post(
|
||||||
|
'/agenda/1/speaker/del/', {'yes': 'yes'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(Speaker.objects.filter(person=self.speaker1, item=self.item1).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpeakerSpeakView(SpeakerViewTestCase):
|
||||||
|
def test_get(self):
|
||||||
|
url = '/agenda/1/speaker/%s/speak/' % self.speaker1.person_id
|
||||||
|
response = self.check_url(url, self.admin_client, 302)
|
||||||
|
self.assertMessage(response, 'Person user:2 is not on the list of item item1.')
|
||||||
|
|
||||||
|
speaker = Speaker.objects.add(self.speaker1, self.item1)
|
||||||
|
response = self.check_url(url, self.admin_client, 302)
|
||||||
|
speaker = Speaker.objects.get(pk=speaker.pk)
|
||||||
|
self.assertIsNotNone(speaker.time)
|
||||||
|
self.assertIsNone(speaker.weight)
|
||||||
|
|
||||||
|
|
||||||
|
class SpeakerListOpenView(SpeakerViewTestCase):
|
||||||
|
def test_get(self):
|
||||||
|
response = self.check_url('/agenda/1/speaker/close/', self.admin_client, 302)
|
||||||
|
item = Item.objects.get(pk=self.item1.pk)
|
||||||
|
self.assertTrue(item.speaker_list_closed)
|
||||||
|
|
||||||
|
response = self.check_url('/agenda/1/speaker/reopen/', self.admin_client, 302)
|
||||||
|
item = Item.objects.get(pk=self.item1.pk)
|
||||||
|
self.assertFalse(item.speaker_list_closed)
|
Loading…
Reference in New Issue
Block a user