From 2f9f3f9f4b9aaf3537b8e2e71b43c4a5fc799fa1 Mon Sep 17 00:00:00 2001 From: Jorran de Wit <jorrandewit@outlook.com> Date: Thu, 14 Sep 2017 23:17:55 +0200 Subject: [PATCH] Production renovation --- production/admin.py | 2 +- production/apps.py | 5 +- production/forms.py | 8 ++ production/managers.py | 4 +- .../migrations/0013_auto_20170914_2220.py | 32 +++++++ .../0014_productionevent_noted_by.py | 22 +++++ .../migrations/0015_auto_20170914_2237.py | 48 ++++++++++ ...ve_productionevent_noted_by_contributor.py | 19 ++++ production/models.py | 6 +- production/permissions.py | 11 +++ production/signals.py | 23 ++--- .../templates/production/completed.html | 2 +- .../partials/production_events.html | 2 +- .../partials/production_stream_card.html | 2 +- .../templates/production/production.html | 92 ++++++++++++------- production/urls.py | 1 + production/views.py | 60 ++++++++---- 17 files changed, 264 insertions(+), 75 deletions(-) create mode 100644 production/migrations/0013_auto_20170914_2220.py create mode 100644 production/migrations/0014_productionevent_noted_by.py create mode 100644 production/migrations/0015_auto_20170914_2237.py create mode 100644 production/migrations/0016_remove_productionevent_noted_by_contributor.py create mode 100644 production/permissions.py diff --git a/production/admin.py b/production/admin.py index 6bf8d0f46..7814dafbd 100644 --- a/production/admin.py +++ b/production/admin.py @@ -4,7 +4,7 @@ from .models import ProductionStream, ProductionEvent, ProductionUser def event_count(obj): - return obj.productionevent_set.count() + return obj.events.count() class ProductionUserInline(admin.StackedInline): diff --git a/production/apps.py b/production/apps.py index 549c6bd6b..8c17f2a4c 100644 --- a/production/apps.py +++ b/production/apps.py @@ -8,7 +8,6 @@ class ProductionConfig(AppConfig): def ready(self): super().ready() - from .models import ProductionStream, ProductionEvent - from .signals import notify_new_stream, notify_new_event - post_save.connect(notify_new_stream, sender=ProductionStream) + from .models import ProductionEvent + from .signals import notify_new_event post_save.connect(notify_new_event, sender=ProductionEvent) diff --git a/production/forms.py b/production/forms.py index 2f43929a2..a81645229 100644 --- a/production/forms.py +++ b/production/forms.py @@ -34,3 +34,11 @@ class AssignOfficerForm(forms.ModelForm): officer = self.cleaned_data['officer'] self.instance.officers.add(officer) return self.instance + + +class UserToOfficerForm(forms.ModelForm): + class Meta: + model = ProductionUser + fields = ( + 'user', + ) diff --git a/production/managers.py b/production/managers.py index ad3234c9c..93b64b4fa 100644 --- a/production/managers.py +++ b/production/managers.py @@ -15,5 +15,5 @@ class ProductionStreamQuerySet(models.QuerySet): class ProductionEventManager(models.Manager): - def get_my_events(self, current_contributor): - return self.filter(noted_by=current_contributor) + def get_my_events(self, production_user): + return self.filter(noted_by=production_user) diff --git a/production/migrations/0013_auto_20170914_2220.py b/production/migrations/0013_auto_20170914_2220.py new file mode 100644 index 000000000..8eaa1babf --- /dev/null +++ b/production/migrations/0013_auto_20170914_2220.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 20:20 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0012_productionstream_officers'), + ] + + operations = [ + migrations.RenameField( + model_name='productionevent', + old_name='noted_by', + new_name='noted_by_contributor', + ), + migrations.AlterField( + model_name='productionstream', + name='officers', + field=models.ManyToManyField(blank=True, related_name='streams', to='production.ProductionUser'), + ), + migrations.AlterField( + model_name='productionuser', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='production_user', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/production/migrations/0014_productionevent_noted_by.py b/production/migrations/0014_productionevent_noted_by.py new file mode 100644 index 000000000..1f728d34d --- /dev/null +++ b/production/migrations/0014_productionevent_noted_by.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 20:20 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0013_auto_20170914_2220'), + ] + + operations = [ + migrations.AddField( + model_name='productionevent', + name='noted_by', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='production.ProductionUser'), + preserve_default=False, + ), + ] diff --git a/production/migrations/0015_auto_20170914_2237.py b/production/migrations/0015_auto_20170914_2237.py new file mode 100644 index 000000000..adb4fcbfb --- /dev/null +++ b/production/migrations/0015_auto_20170914_2237.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 20:37 +from __future__ import unicode_literals + +from django.contrib.auth.models import Group, User +from django.db import migrations + + +def contributor_to_officer(apps, schema_editor): + # Create ProductionUser for all current Officers + ProductionUser = apps.get_model('production', 'ProductionUser') + officers = Group.objects.get(name='Production Officers') + for user in officers.user_set.all(): + ProductionUser.objects.get_or_create(user__id=user.id) + print('\n - Production Officers transfered to ProductionUser') + + # Transfer all Events + ProductionEvent = apps.get_model('production', 'ProductionEvent') + for event in ProductionEvent.objects.all(): + user = User.objects.get(contributor__id=event.noted_by_contributor.id) + event.noted_by.id = user.production_user.id + event.save() + print(' - ProductionEvents updated') + + return + + +def officer_to_contributor(apps, schema_editor): + # Transfer all Events + ProductionEvent = apps.get_model('production', 'ProductionEvent') + for event in ProductionEvent.objects.all(): + user = User.objects.get(production_user__id=event.noted_by.id) + event.noted_by_contributor.id = user.contributor.id + event.save() + print('\n - ProductionEvents updated') + + return + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0014_productionevent_noted_by'), + ] + + operations = [ + migrations.RunPython(contributor_to_officer, officer_to_contributor) + ] diff --git a/production/migrations/0016_remove_productionevent_noted_by_contributor.py b/production/migrations/0016_remove_productionevent_noted_by_contributor.py new file mode 100644 index 000000000..67736d491 --- /dev/null +++ b/production/migrations/0016_remove_productionevent_noted_by_contributor.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 20:46 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0015_auto_20170914_2237'), + ] + + operations = [ + migrations.RemoveField( + model_name='productionevent', + name='noted_by_contributor', + ), + ] diff --git a/production/models.py b/production/models.py index 2dc2ef978..1f6784006 100644 --- a/production/models.py +++ b/production/models.py @@ -42,16 +42,16 @@ class ProductionStream(models.Model): return reverse('production:completed') + '#stream_' + str(self.id) def total_duration(self): - totdur = self.productionevent_set.aggregate(models.Sum('duration')) + totdur = self.events.aggregate(models.Sum('duration')) return totdur['duration__sum'] class ProductionEvent(models.Model): - stream = models.ForeignKey(ProductionStream, on_delete=models.CASCADE) + stream = models.ForeignKey(ProductionStream, on_delete=models.CASCADE, related_name='events') event = models.CharField(max_length=64, choices=PRODUCTION_EVENTS) comments = models.TextField(blank=True, null=True) noted_on = models.DateTimeField(default=timezone.now) - noted_by = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) + noted_by = models.ForeignKey('production.ProductionUser', on_delete=models.CASCADE) duration = models.DurationField(blank=True, null=True) objects = ProductionEventManager() diff --git a/production/permissions.py b/production/permissions.py new file mode 100644 index 000000000..54012de7d --- /dev/null +++ b/production/permissions.py @@ -0,0 +1,11 @@ +from django.contrib.auth.decorators import user_passes_test + + +def is_production_user(): + """Requires user to be a ProductionUser.""" + def test(u): + if u.is_authenticated(): + if hasattr(u, 'production_user') and u.production_user: + return True + return False + return user_passes_test(test) diff --git a/production/signals.py b/production/signals.py index 6a3102f92..7cdd7872c 100644 --- a/production/signals.py +++ b/production/signals.py @@ -1,17 +1,12 @@ -from django.contrib.auth.models import Group, User - from notifications.signals import notify -def notify_new_stream(sender, instance, created, **kwargs): +def notify_new_stream(sender, instance, recipient, **kwargs): """ Notify the production team about a new Production Stream created. """ - if created: - production_officers = User.objects.filter(groups__name='Production Officers') - editorial_college = Group.objects.get(name='Editorial College') - for user in production_officers: - notify.send(sender=sender, recipient=user, actor=editorial_college, verb=' accepted a submission. A new productionstream has been started.', target=instance) + notify.send(sender=sender, recipient=recipient, actor=sender, + verb=' assigned you to a Production Stream.', target=instance) def notify_new_event(sender, instance, created, **kwargs): @@ -19,15 +14,15 @@ def notify_new_event(sender, instance, created, **kwargs): Notify the production team about a new Production Event created. """ if created: - production_officers = User.objects.filter(groups__name='Production Officers') - for user in production_officers: - notify.send(sender=sender, recipient=user, actor=instance.noted_by.user, verb=' created a new Production Event ', target=instance) + for officer in instance.stream.officers.all(): + notify.send(sender=sender, recipient=officer.user, actor=instance.noted_by.user, + verb=' created a new Production Event.', target=instance) def notify_stream_completed(sender, instance, **kwargs): """ Notify the production team about a Production Stream being completed. """ - production_officers = User.objects.filter(groups__name='Production Officers') - for user in production_officers: - notify.send(sender=sender, recipient=user, actor=sender, verb=' marked Production Stream as completed.', target=instance) + for officer in instance.officers.all(): + notify.send(sender=sender, recipient=officer.user, actor=sender, + verb=' marked Production Stream as completed.', target=instance) diff --git a/production/templates/production/completed.html b/production/templates/production/completed.html index 38de0e2ce..bf3507da6 100644 --- a/production/templates/production/completed.html +++ b/production/templates/production/completed.html @@ -24,7 +24,7 @@ <div class="w-100" id="stream_{{stream.id}}">{% include 'submissions/_submission_card_content_sparse.html' with submission=stream.submission %}</div> <div class="card-body"> <h3>Events</h3> - {% include 'production/partials/production_events.html' with events=stream.productionevent_set.all %} + {% include 'production/partials/production_events.html' with events=stream.events.all %} </div> </li> {% empty %} diff --git a/production/templates/production/partials/production_events.html b/production/templates/production/partials/production_events.html index 664d620d8..dd91d748f 100644 --- a/production/templates/production/partials/production_events.html +++ b/production/templates/production/partials/production_events.html @@ -4,7 +4,7 @@ {% for event in events %} <li id="event_{{ event.id }}"> <p class="mb-0 font-weight-bold">{{ event.get_event_display }} - {% if event.noted_by == request.user.contributor %} + {% if event.noted_by == request.user.production_user %} · <a href="{% url 'production:update_event' event.id %}">Edit</a> · <a class="text-danger" href="{% url 'production:delete_event' event.id %}">Delete</a> {% endif %} diff --git a/production/templates/production/partials/production_stream_card.html b/production/templates/production/partials/production_stream_card.html index f1902c96e..baaee1ab5 100644 --- a/production/templates/production/partials/production_stream_card.html +++ b/production/templates/production/partials/production_stream_card.html @@ -15,7 +15,7 @@ {% endfor %} </ul> <h3>Events</h3> - {% include 'production/partials/production_events.html' with events=stream.productionevent_set.all %} + {% include 'production/partials/production_events.html' with events=stream.events.all %} <br/> diff --git a/production/templates/production/production.html b/production/templates/production/production.html index 7578c131d..841d159b7 100644 --- a/production/templates/production/production.html +++ b/production/templates/production/production.html @@ -34,6 +34,11 @@ <a href="#teamtimesheets" class="nav-link" data-toggle="tab">Team Timesheets</a> </li> {% endif %} + {% if perms.scipost.can_promote_user_to_production_officer %} + <li class="nav-item btn btn-secondary"> + <a href="#officers" class="nav-link" data-toggle="tab">Production Officers</a> + </li> + {% endif %} </ul> </div> </div> @@ -66,8 +71,8 @@ <td>{{ stream.submission.author_list }}</td> <td>{{ stream.submission.title }}</td> <td>{{ stream.submission.acceptance_date|date:"Y-m-d" }}</td> - <td>{{ stream.productionevent_set.last.get_event_display }}</td> - <td>{{ stream.productionevent_set.last.noted_on }}</td> + <td>{{ stream.events.last.get_event_display }}</td> + <td>{{ stream.events.last.noted_on }}</td> </tr> <tr id="collapse{{ stream.id }}" class="collapse" role="tabpanel" aria-labelledby="heading{{ stream.id }}" style="background-color: #fff;"> <td colspan="5"> @@ -119,39 +124,60 @@ </div> {% if perms.scipost.can_view_timesheets %} - <div class="tab-pane" id="teamtimesheets" role="tabpanel"> - <div class="row"> - <div class="col-12"> - <h2 class="highlight">Team Timesheets</h2> - </div> - </div> - - <table class="table table-hover mb-5"> - <thead class="thead-default"> - <tr> - <th>Name</th> - </tr> - </thead> + <div class="tab-pane" id="teamtimesheets" role="tabpanel"> + <div class="row"> + <div class="col-12"> + <h2 class="highlight">Team Timesheets</h2> + </div> + </div> + + <table class="table table-hover mb-5"> + <thead class="thead-default"> + <tr> + <th>Name</th> + </tr> + </thead> + + <tbody id="accordion" role="tablist" aria-multiselectable="true"> + {% for member in production_team.all %} + <tr data-toggle="collapse" data-parent="#accordion" href="#collapse{{ member.id }}" aria-expanded="true" aria-controls="collapse{{ member.id }}" style="cursor: pointer;"> + <td>{{ member }}</td> + </tr> + <tr id="collapse{{ member.id }}" class="collapse" role="tabpanel" aria-labelledby="heading{{ member.id }}" style="background-color: #fff;"> + <td> + {% include 'production/partials/production_timesheet_card.html' with events=member.productionevent_set.all %} + </td> + </tr> + {% empty %} + <tr> + <td>No Team Member found.</td> + </tr> + {% endfor %} + </tbody> + </table> - <tbody id="accordion" role="tablist" aria-multiselectable="true"> - {% for member in production_team.all %} - <tr data-toggle="collapse" data-parent="#accordion" href="#collapse{{ member.id }}" aria-expanded="true" aria-controls="collapse{{ member.id }}" style="cursor: pointer;"> - <td>{{ member }}</td> - </tr> - <tr id="collapse{{ member.id }}" class="collapse" role="tabpanel" aria-labelledby="heading{{ member.id }}" style="background-color: #fff;"> - <td> - {% include 'production/partials/production_timesheet_card.html' with events=member.productionevent_set.all %} - </td> - </tr> - {% empty %} - <tr> - <td>No Team Member found.</td> - </tr> - {% endfor %} - </tbody> - </table> + </div> + {% endif %} - </div> + {% if perms.scipost.can_promote_user_to_production_officer %} + <div class="tab-pane" id="officers" role="tabpanel"> + <h2 class="highlight">Production Officers</h2> + <h3>Current Production Officer</h3> + <ul> + {% for officer in production_officers %} + <li>{{ officer }}</li> + {% endfor %} + </ul> + + {% if new_officer_form %} + <h3>Promote user to Production Officer</h3> + <form action="{% url 'production:user_to_officer' %}" method="post"> + {% csrf_token %} + {{ new_officer_form|bootstrap }} + <input type="submit" class="btn btn-primary" value="Promote to Production Officer"> + </form> + {% endif %} + </div> {% endif %} </div> diff --git a/production/urls.py b/production/urls.py index 95559d171..116a3a093 100644 --- a/production/urls.py +++ b/production/urls.py @@ -5,6 +5,7 @@ from production import views as production_views urlpatterns = [ url(r'^$', production_views.production, name='production'), url(r'^completed$', production_views.completed, name='completed'), + url(r'^officers/new$', production_views.user_to_officer, name='user_to_officer'), 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$', diff --git a/production/views.py b/production/views.py index 5c07863b4..4dddd7984 100644 --- a/production/views.py +++ b/production/views.py @@ -1,6 +1,7 @@ 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,30 +12,32 @@ from guardian.decorators import permission_required from .constants import PRODUCTION_STREAM_COMPLETED from .models import ProductionUser, ProductionStream, ProductionEvent -from .forms import ProductionEventForm, AssignOfficerForm -from .signals import notify_stream_completed - -from scipost.models import Contributor +from .forms import ProductionEventForm, AssignOfficerForm, UserToOfficerForm +from .permissions import is_production_user +from .signals import notify_stream_completed, notify_new_stream ###################### # Production process # ###################### +@is_production_user() @permission_required('scipost.can_view_production', return_403=True) def production(request): """ Overview page for the production process. All papers with accepted but not yet published status are included here. """ - 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') + streams = ProductionStream.objects.ongoing() + if not request.user.has_perm('scipost.can_assign_production_officer'): + # Restrict stream queryset if user is not supervisor + streams = streams.filter_for_user(request.user.production_user) + streams = streams.order_by('opened') + prodevent_form = ProductionEventForm() assignment_form = AssignOfficerForm() ownevents = ProductionEvent.objects.filter( - noted_by=request.user.contributor, + noted_by=request.user.production_user, duration__gte=datetime.timedelta(minutes=1)).order_by('-noted_on') context = { 'streams': streams, @@ -43,11 +46,15 @@ def production(request): 'ownevents': ownevents, } if request.user.has_perm('scipost.can_view_timesheets'): - context['production_team'] = Contributor.objects.filter( - user__groups__name='Production Officers').order_by('user__last_name') + context['production_team'] = ProductionUser.objects.all() + + if request.user.has_perm('scipost.can_promote_user_to_production_officer'): + context['production_officers'] = ProductionUser.objects.all() + context['new_officer_form'] = UserToOfficerForm() return render(request, 'production/production.html', context) +@is_production_user() @permission_required('scipost.can_view_production', return_403=True) def completed(request): """ @@ -58,6 +65,21 @@ def completed(request): return render(request, 'production/completed.html', context) +@is_production_user() +@permission_required('scipost.can_promote_user_to_production_officer') +def user_to_officer(request): + form = UserToOfficerForm(request.POST or None) + if form.is_valid(): + officer = form.save() + + # Add permission group + group = Group.objects.get(name='Production Officers') + officer.user.groups.add(group) + messages.success(request, '{user} promoted to Production Officer'.format(user=officer)) + return redirect(reverse('production:production')) + + +@is_production_user() @permission_required('scipost.can_view_production', return_403=True) def add_event(request, stream_id): stream = get_object_or_404(ProductionStream.objects.ongoing(), pk=stream_id) @@ -65,27 +87,30 @@ def add_event(request, stream_id): if prodevent_form.is_valid(): prodevent = prodevent_form.save(commit=False) prodevent.stream = stream - prodevent.noted_by = request.user.contributor + prodevent.noted_by = request.user.production_user prodevent.save() else: messages.warning(request, 'The form was invalidly filled.') return redirect(reverse('production:production')) +@is_production_user() @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'))) + officer = form.cleaned_data.get('officer') + messages.success(request, 'Officer {officer} has been assigned.'.format(officer=officer)) + notify_new_stream(request.user, stream, officer.user) else: for key, error in form.errors.items(): messages.warning(request, error[0]) return redirect(reverse('production:production')) +@is_production_user() @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) @@ -99,6 +124,7 @@ def remove_officer(request, stream_id, officer_id): return redirect(reverse('production:production')) +@method_decorator(is_production_user(), name='dispatch') @method_decorator(permission_required('scipost.can_view_production', raise_exception=True), name='dispatch') class UpdateEventView(UpdateView): @@ -108,13 +134,14 @@ class UpdateEventView(UpdateView): slug_url_kwarg = 'event_id' def get_queryset(self): - return self.model.objects.get_my_events(self.request.user.contributor) + return self.model.objects.get_my_events(self.request.user.production_user) def form_valid(self, form): messages.success(self.request, 'Event updated succesfully') return super().form_valid(form) +@method_decorator(is_production_user(), name='dispatch') @method_decorator(permission_required('scipost.can_view_production', raise_exception=True), name='dispatch') class DeleteEventView(DeleteView): @@ -123,7 +150,7 @@ class DeleteEventView(DeleteView): slug_url_kwarg = 'event_id' def get_queryset(self): - return self.model.objects.get_my_events(self.request.user.contributor) + return self.model.objects.get_my_events(self.request.user.production_user) def form_valid(self, form): messages.success(self.request, 'Event deleted succesfully') @@ -133,6 +160,7 @@ class DeleteEventView(DeleteView): return self.object.get_absolute_url() +@is_production_user() @permission_required('scipost.can_publish_accepted_submission', return_403=True) def mark_as_completed(request, stream_id): stream = get_object_or_404(ProductionStream.objects.ongoing(), pk=stream_id) -- GitLab