diff --git a/production/admin.py b/production/admin.py index 15b7b61d8241e0c94276da31c3f9a9af0c5b185b..6bf8d0f464ebced0f910e8a02a32e9af9af7f893 100644 --- a/production/admin.py +++ b/production/admin.py @@ -28,4 +28,5 @@ class ProductionStreamAdmin(admin.ModelAdmin): ) +admin.site.register(ProductionUser) admin.site.register(ProductionStream, ProductionStreamAdmin) diff --git a/production/forms.py b/production/forms.py index fec37e4b4ba232b9432bfc52c173229c640c709a..2f43929a213f7b7eb365ee34305cd9485b195e4e 100644 --- a/production/forms.py +++ b/production/forms.py @@ -1,6 +1,6 @@ from django import forms -from .models import ProductionEvent +from .models import ProductionUser, ProductionStream, ProductionEvent class ProductionEventForm(forms.ModelForm): @@ -15,3 +15,22 @@ class ProductionEventForm(forms.ModelForm): 'comments': forms.Textarea(attrs={'rows': 4}), 'duration': forms.TextInput(attrs={'placeholder': 'HH:MM:SS'}) } + + +class AssignOfficerForm(forms.ModelForm): + officer = forms.ModelChoiceField(queryset=ProductionUser.objects.all()) + + class Meta: + model = ProductionStream + fields = () + + def clean_officer(self): + officer = self.cleaned_data['officer'] + if officer in self.instance.officers.all(): + self.add_error('officer', 'Officer already assigned to Stream') + return officer + + def save(self, commit=True): + officer = self.cleaned_data['officer'] + self.instance.officers.add(officer) + return self.instance diff --git a/production/managers.py b/production/managers.py index ca9b5d0ba99fb56cdce96d96963337445f27d16e..ad3234c9c317d699167588756ac2b802b5cd03ea 100644 --- a/production/managers.py +++ b/production/managers.py @@ -3,13 +3,16 @@ from django.db import models from .constants import PRODUCTION_STREAM_COMPLETED, PRODUCTION_STREAM_ONGOING -class ProductionStreamManager(models.Manager): +class ProductionStreamQuerySet(models.QuerySet): def completed(self): return self.filter(status=PRODUCTION_STREAM_COMPLETED) def ongoing(self): return self.filter(status=PRODUCTION_STREAM_ONGOING) + def filter_for_user(self, production_user): + return self.filter(officers=production_user) + class ProductionEventManager(models.Manager): def get_my_events(self, current_contributor): diff --git a/production/migrations/0012_productionstream_officers.py b/production/migrations/0012_productionstream_officers.py new file mode 100644 index 0000000000000000000000000000000000000000..37835f8f2b46f2c8f4b02aa52b031145ca3cc27a --- /dev/null +++ b/production/migrations/0012_productionstream_officers.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 18:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0011_productionuser'), + ] + + operations = [ + migrations.AddField( + model_name='productionstream', + name='officers', + field=models.ManyToManyField(blank=True, to='production.ProductionUser'), + ), + ] diff --git a/production/models.py b/production/models.py index 3540573dd4b178915446f647ff2aaa7183a0a6ff..2dc2ef978ceecf4da037bc6690d542f6ade1f51f 100644 --- a/production/models.py +++ b/production/models.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.utils import timezone from .constants import PRODUCTION_STREAM_STATUS, PRODUCTION_STREAM_ONGOING, PRODUCTION_EVENTS -from .managers import ProductionStreamManager, ProductionEventManager +from .managers import ProductionStreamQuerySet, ProductionEventManager class ProductionUser(models.Model): @@ -12,7 +12,8 @@ class ProductionUser(models.Model): Production Officers will have a ProductionUser object related to their account to relate all production related actions to. """ - user = models.OneToOneField(User, on_delete=models.PROTECT, unique=True) + user = models.OneToOneField(User, on_delete=models.PROTECT, unique=True, + related_name='production_user') # objects = ProductionUserQuerySet.as_manager() -- Not implemented yet @@ -26,8 +27,10 @@ class ProductionStream(models.Model): closed = models.DateTimeField(default=timezone.now) status = models.CharField(max_length=32, choices=PRODUCTION_STREAM_STATUS, default=PRODUCTION_STREAM_ONGOING) + officers = models.ManyToManyField('production.ProductionUser', blank=True, + related_name='streams') - objects = ProductionStreamManager() + objects = ProductionStreamQuerySet.as_manager() def __str__(self): return '{arxiv}, {title}'.format(arxiv=self.submission.arxiv_identifier_w_vn_nr, diff --git a/production/templates/production/partials/production_events.html b/production/templates/production/partials/production_events.html index e7d168b13c52553b541c78e7768c03fb95d45049..664d620d8117a709041c44e4bc5f9f9a8bf4f6d3 100644 --- a/production/templates/production/partials/production_events.html +++ b/production/templates/production/partials/production_events.html @@ -21,3 +21,8 @@ <li>No events were found.</li> {% endfor %} </ul> + +{% if stream.total_duration %} + <hr class="sm"> + <p class="pl-4 ml-3">Total duration for this stream: <strong>{{ stream.total_duration|duration }}</strong></p> +{% endif %} diff --git a/production/templates/production/partials/production_stream_card.html b/production/templates/production/partials/production_stream_card.html index 94827918f10e00bb528393ffcf70080d4c470209..f1902c96ed5c98f1f963e14c74d3c532abeec881 100644 --- a/production/templates/production/partials/production_stream_card.html +++ b/production/templates/production/partials/production_stream_card.html @@ -1,29 +1,51 @@ {% load bootstrap %} -{% load scipost_extras %} <div class="w-100" id="stream_{{stream.id}}"> {% include 'submissions/_submission_card_content_sparse.html' with submission=stream.submission %} </div> <div class="card-body"> <div class="row"> - <div class="{% if form %}col-lg-7{% else %}col-12{% endif %}"> + <div class="{% if prodevent_form %}col-lg-7{% else %}col-12{% endif %}"> + <h3>Officers</h3> + <ul> + {% for officer in stream.officers.all %} + <li>{{ officer }}{% if perms.scipost.can_assign_production_officer %} · <a href="{% url 'production:remove_officer' stream_id=stream.id officer_id=officer.id %}" class="text-danger">Remove from stream</a>{% endif %}</li> + {% empty %} + <li>No Officer assigned yet.</li> + {% endfor %} + </ul> <h3>Events</h3> {% include 'production/partials/production_events.html' with events=stream.productionevent_set.all %} <br/> - {% if stream.total_duration %} - <h3>Total duration for this stream: {{ stream.total_duration|duration }}</h3> - {% endif %} - {% if perms.scipost.can_publish_accepted_submission %} - <h3><a href="{% url 'production:mark_as_completed' stream_id=stream.id %}">Mark this stream as completed</a></h3> + + {% if perms.scipost.can_publish_accepted_submission or perms.scipost.can_assign_production_officer %} + <h3>Actions</h3> + <ul class=""> + {% if perms.scipost.can_assign_production_officer and assignment_form %} + <li> + <a href="javascript:;" data-toggle="toggle" data-target="#add_officer_{{stream.id}}">Assign Production Officer to this stream</a> + <div id="add_officer_{{stream.id}}" style="display: none;"> + <form class="my-3" action="{% url 'production:add_officer' stream_id=stream.id %}" method="post"> + {% csrf_token %} + {{ assignment_form|bootstrap_inline }} + <input type="submit" class="btn btn-outline-primary" name="submit" value="Add officer"> + </form> + </div> + </li> + {% endif %} + {% if perms.scipost.can_publish_accepted_submission %} + <li><a href="{% url 'production:mark_as_completed' stream_id=stream.id %}">Mark this stream as completed</a></li> + {% endif %} + </ul> {% endif %} </div> - {% if form %} + {% if prodevent_form %} <div class="col-lg-5 mt-4 mt-lg-0"> <h3>Add an event to this production stream:</h3> <form action="{% url 'production:add_event' stream_id=stream.id %}" method="post"> {% csrf_token %} - {{ form|bootstrap }} + {{ prodevent_form|bootstrap }} <input type="submit" class="btn btn-secondary" name="submit" value="Submit"> </form> </div> diff --git a/production/templates/production/production.html b/production/templates/production/production.html index 263bd72112613f0521e42b38e401a1d51e0dd7d6..7578c131db28dc0c6dde2a2111a9e6d95fa7ffaa 100644 --- a/production/templates/production/production.html +++ b/production/templates/production/production.html @@ -71,7 +71,7 @@ </tr> <tr id="collapse{{ stream.id }}" class="collapse" role="tabpanel" aria-labelledby="heading{{ stream.id }}" style="background-color: #fff;"> <td colspan="5"> - {% include 'production/partials/production_stream_card.html' with stream=stream form=prodevent_form %} + {% include 'production/partials/production_stream_card.html' with stream=stream prodevent_form=prodevent_form assignment_form=assignment_form %} </td> </tr> {% empty %} diff --git a/production/urls.py b/production/urls.py index cc5bf527b92c39690aabca17762514101ff57daf..95559d171c4eab56ddd74d36570683952face65b 100644 --- a/production/urls.py +++ b/production/urls.py @@ -7,6 +7,10 @@ urlpatterns = [ url(r'^completed$', production_views.completed, name='completed'), url(r'^streams/(?P<stream_id>[0-9]+)/events/add$', production_views.add_event, name='add_event'), + url(r'^streams/(?P<stream_id>[0-9]+)/officer/add$', + production_views.add_officer, name='add_officer'), + url(r'^streams/(?P<stream_id>[0-9]+)/officer/(?P<officer_id>[0-9]+)/remove$', + production_views.remove_officer, name='remove_officer'), url(r'^streams/(?P<stream_id>[0-9]+)/mark_completed$', production_views.mark_as_completed, name='mark_as_completed'), url(r'^events/(?P<event_id>[0-9]+)/edit', diff --git a/production/views.py b/production/views.py index 517c09c116ecdd90c20cc0dcdc5ba199f194a202..5c07863b41a8ba8002a4f53a7934248c0e074642 100644 --- a/production/views.py +++ b/production/views.py @@ -1,7 +1,6 @@ import datetime from django.contrib import messages -from django.contrib.auth.models import Group from django.core.urlresolvers import reverse from django.shortcuts import get_object_or_404, render, redirect from django.utils import timezone @@ -11,8 +10,8 @@ from django.views.generic.edit import UpdateView, DeleteView from guardian.decorators import permission_required from .constants import PRODUCTION_STREAM_COMPLETED -from .models import ProductionStream, ProductionEvent -from .forms import ProductionEventForm +from .models import ProductionUser, ProductionStream, ProductionEvent +from .forms import ProductionEventForm, AssignOfficerForm from .signals import notify_stream_completed from scipost.models import Contributor @@ -28,14 +27,19 @@ def production(request): Overview page for the production process. All papers with accepted but not yet published status are included here. """ - streams = ProductionStream.objects.ongoing().order_by('opened') + if request.user.has_perm('scipost.can_assign_production_officer'): + streams = ProductionStream.objects.ongoing().order_by('opened') + else: + streams = ProductionStream.objects.ongoing().filter_for_user(request.user.production_user).order_by('opened') prodevent_form = ProductionEventForm() + assignment_form = AssignOfficerForm() ownevents = ProductionEvent.objects.filter( noted_by=request.user.contributor, duration__gte=datetime.timedelta(minutes=1)).order_by('-noted_on') context = { 'streams': streams, 'prodevent_form': prodevent_form, + 'assignment_form': assignment_form, 'ownevents': ownevents, } if request.user.has_perm('scipost.can_view_timesheets'): @@ -68,6 +72,33 @@ def add_event(request, stream_id): return redirect(reverse('production:production')) +@permission_required('scipost.can_assign_production_officer', return_403=True) +def add_officer(request, stream_id): + stream = get_object_or_404(ProductionStream.objects.ongoing(), pk=stream_id) + form = AssignOfficerForm(request.POST or None, instance=stream) + if form.is_valid(): + form.save() + messages.success(request, 'Officer {officer} has been assigned.'.format( + officer=form.cleaned_data.get('officer'))) + else: + for key, error in form.errors.items(): + messages.warning(request, error[0]) + return redirect(reverse('production:production')) + + +@permission_required('scipost.can_assign_production_officer', return_403=True) +def remove_officer(request, stream_id, officer_id): + stream = get_object_or_404(ProductionStream.objects.ongoing(), pk=stream_id) + try: + officer = stream.officers.get(pk=officer_id) + except ProductionUser.DoesNotExist: + return redirect(reverse('production:production')) + + stream.officers.remove(officer) + messages.success(request, 'Officer {officer} has been removed.'.format(officer=officer)) + return redirect(reverse('production:production')) + + @method_decorator(permission_required('scipost.can_view_production', raise_exception=True), name='dispatch') class UpdateEventView(UpdateView):