Commit 1af53b13 by Waheed Ahmed Committed by GitHub

Merge pull request #219 from edx/waheed/ecom-4979-create-workflow-states

Create workflow states
parents f8bde79f c89826d0
from django.contrib import admin
from course_discovery.apps.publisher.models import Course, CourseRun, Seat
from course_discovery.apps.publisher.models import Course, CourseRun, Seat, State
admin.site.register(Course)
admin.site.register(CourseRun)
admin.site.register(Seat)
admin.site.register(State)
......@@ -45,6 +45,7 @@ class CourseRunForm(BaseCourseForm):
class Meta:
model = CourseRun
fields = '__all__'
exclude = ('state',)
class SeatForm(BaseCourseForm):
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
import django_fsm
import course_discovery.apps.publisher.models
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('publisher', '0003_auto_20160801_1757'),
]
operations = [
migrations.CreateModel(
name='HistoricalState',
fields=[
('id', models.IntegerField(blank=True, auto_created=True, verbose_name='ID', db_index=True)),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('name', django_fsm.FSMField(choices=[('draft', 'Draft'), ('needs_review', 'Needs Review'), ('needs_final_approval', 'Needs Final Approval'), ('finalized', 'Finalized'), ('published', 'Published')], default='draft', max_length=50)),
('history_id', models.AutoField(serialize=False, primary_key=True)),
('history_date', models.DateTimeField()),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True, related_name='+')),
],
options={
'get_latest_by': 'history_date',
'ordering': ('-history_date', '-history_id'),
'verbose_name': 'historical state',
},
),
migrations.CreateModel(
name='State',
fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('name', django_fsm.FSMField(choices=[('draft', 'Draft'), ('needs_review', 'Needs Review'), ('needs_final_approval', 'Needs Final Approval'), ('finalized', 'Finalized'), ('published', 'Published')], default='draft', max_length=50)),
],
options={
'get_latest_by': 'modified',
'ordering': ('-modified', '-created'),
'abstract': False,
},
bases=(models.Model, course_discovery.apps.publisher.models.ChangedByMixin),
),
migrations.AddField(
model_name='courserun',
name='state',
field=models.ForeignKey(blank=True, to='publisher.State', null=True),
),
migrations.AddField(
model_name='historicalcourserun',
name='state',
field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='publisher.State', related_name='+', null=True),
),
]
import logging
from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from django_fsm import FSMField, transition
from simple_history.models import HistoricalRecords
from sortedm2m.fields import SortedManyToManyField
......@@ -18,6 +21,55 @@ class ChangedByMixin(object):
changed_by = models.ForeignKey(User, null=True, blank=True)
class State(TimeStampedModel, ChangedByMixin):
""" Publisher Workflow State Model. """
DRAFT = 'draft'
NEEDS_REVIEW = 'needs_review'
NEEDS_FINAL_APPROVAL = 'needs_final_approval'
FINALIZED = 'finalized'
PUBLISHED = 'published'
CHOICES = (
(DRAFT, _('Draft')),
(NEEDS_REVIEW, _('Needs Review')),
(NEEDS_FINAL_APPROVAL, _('Needs Final Approval')),
(FINALIZED, _('Finalized')),
(PUBLISHED, _('Published'))
)
name = FSMField(default=DRAFT, choices=CHOICES)
history = HistoricalRecords()
def __str__(self):
return self.get_name_display()
@transition(field=name, source='*', target=DRAFT)
def draft(self):
# TODO: send email etc.
pass
@transition(field=name, source=DRAFT, target=NEEDS_REVIEW)
def needs_review(self):
# TODO: send email etc.
pass
@transition(field=name, source=NEEDS_REVIEW, target=NEEDS_FINAL_APPROVAL)
def needs_final_approval(self):
# TODO: send email etc.
pass
@transition(field=name, source=NEEDS_FINAL_APPROVAL, target=FINALIZED)
def finalized(self):
# TODO: send email etc.
pass
@transition(field=name, source=FINALIZED, target=PUBLISHED)
def publish(self):
# TODO: send email etc.
pass
class Course(TimeStampedModel, ChangedByMixin):
""" Publisher Course model. It contains fields related to the course intake form."""
......@@ -70,6 +122,8 @@ class CourseRun(TimeStampedModel, ChangedByMixin):
(PRIORITY_LEVEL_5, _('Level 5')),
)
state = models.ForeignKey(State, null=True, blank=True)
course = models.ForeignKey(Course, related_name='publisher_course_runs')
lms_course_id = models.CharField(max_length=255, unique=True, null=True, blank=True)
......@@ -132,6 +186,24 @@ class CourseRun(TimeStampedModel, ChangedByMixin):
def __str__(self):
return '{course}: {start_date}'.format(course=self.course.title, start_date=self.start)
def change_state(self, target=State.DRAFT):
if target == State.NEEDS_REVIEW:
self.state.needs_review()
elif target == State.NEEDS_FINAL_APPROVAL:
self.state.needs_final_approval()
elif target == State.FINALIZED:
self.state.finalized()
elif target == State.PUBLISHED:
self.state.publish()
else:
self.state.draft()
self.state.save()
@property
def current_state(self):
return self.state.get_name_display()
class Seat(TimeStampedModel, ChangedByMixin):
""" Seat model. """
......@@ -169,3 +241,17 @@ class Seat(TimeStampedModel, ChangedByMixin):
def __str__(self):
return '{course}: {type}'.format(course=self.course_run.course.title, type=self.type)
@receiver(pre_save, sender=CourseRun)
def initialize_workflow(sender, instance, **kwargs): # pylint: disable=unused-argument
""" Create Workflow State For CourseRun Before Saving. """
create_workflow_state(instance)
def create_workflow_state(course_run):
""" Create Workflow State If Not Present."""
if not course_run.state:
state = State()
state.save()
course_run.state = state
......@@ -8,7 +8,13 @@ from course_discovery.apps.core.models import Currency
from course_discovery.apps.course_metadata.tests import factories
from course_discovery.apps.course_metadata.models import CourseRun as CourseMetadataCourseRun
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.models import Course, CourseRun, Seat
from course_discovery.apps.publisher.models import Course, CourseRun, Seat, State
class StateFactory(factory.DjangoModelFactory):
class Meta:
model = State
class CourseFactory(factory.DjangoModelFactory):
......@@ -32,6 +38,7 @@ class CourseFactory(factory.DjangoModelFactory):
class CourseRunFactory(factory.DjangoModelFactory):
course = factory.SubFactory(CourseFactory)
state = factory.SubFactory(StateFactory)
start = FuzzyDateTime(datetime(2014, 1, 1, tzinfo=UTC))
end = FuzzyDateTime(datetime(2014, 1, 1, tzinfo=UTC)).end_dt
enrollment_start = FuzzyDateTime(datetime(2014, 1, 1, tzinfo=UTC))
......
# pylint: disable=no-member
import ddt
from django.test import TestCase
from django_fsm import TransitionNotAllowed
from course_discovery.apps.publisher.models import State
from course_discovery.apps.publisher.tests import factories
@ddt.ddt
class CourseRunTests(TestCase):
""" Tests for the publisher `CourseRun` model. """
def setUp(self):
super(CourseRunTests, self).setUp()
self.course_run = factories.CourseRunFactory()
@classmethod
def setUpClass(cls):
super(CourseRunTests, cls).setUpClass()
cls.course_run = factories.CourseRunFactory()
def test_str(self):
""" Verify casting an instance to a string returns a string containing the course title and start date. """
......@@ -20,6 +25,26 @@ class CourseRunTests(TestCase):
)
)
@ddt.unpack
@ddt.data(
(State.DRAFT, State.NEEDS_REVIEW),
(State.NEEDS_REVIEW, State.NEEDS_FINAL_APPROVAL),
(State.NEEDS_FINAL_APPROVAL, State.FINALIZED),
(State.FINALIZED, State.PUBLISHED),
(State.PUBLISHED, State.DRAFT),
)
def test_workflow_change_state(self, source_state, target_state):
""" Verify that we can change the workflow states according to allowed transition. """
self.assertEqual(self.course_run.state.name, source_state)
self.course_run.change_state(target=target_state)
self.assertEqual(self.course_run.state.name, target_state)
def test_workflow_change_state_not_allowed(self):
""" Verify that we can't change the workflow state from `DRAFT` to `PUBLISHED` directly. """
self.assertEqual(self.course_run.state.name, State.DRAFT)
with self.assertRaises(TransitionNotAllowed):
self.course_run.change_state(target=State.PUBLISHED)
class CourseTests(TestCase):
""" Tests for the publisher `Course` model. """
......@@ -48,3 +73,18 @@ class SeatTests(TestCase):
course=self.seat.course_run.course.title, type=self.seat.type
)
)
class StateTests(TestCase):
""" Tests for the publisher `State` model. """
def setUp(self):
super(StateTests, self).setUp()
self.state = factories.StateFactory()
def test_str(self):
""" Verify casting an instance to a string returns a string containing the current state display name. """
self.assertEqual(
str(self.state),
self.state.get_name_display()
)
import ddt
from django.core.urlresolvers import reverse
from django.forms import model_to_dict
from django.test import TestCase
from course_discovery.apps.publisher.models import Course, CourseRun, Seat
from course_discovery.apps.publisher.models import Course, CourseRun, Seat, State
from course_discovery.apps.publisher.tests import factories
from course_discovery.apps.publisher.wrappers import CourseRunWrapper
......@@ -171,7 +170,7 @@ class SeatsCreateUpdateViewTests(TestCase):
self.seat_dict
)
seat = Seat.objects.get(id=self.seat.id)
# Assert that we change seat type.
# Assert that we can change seat type.
self.assertEqual(seat.type, Seat.HONOR)
self.assertRedirects(
......@@ -182,7 +181,6 @@ class SeatsCreateUpdateViewTests(TestCase):
)
@ddt.ddt
class CourseRunDetailTests(TestCase):
""" Tests for the course-run detail view. """
......@@ -319,3 +317,37 @@ class CourseRunDetailTests(TestCase):
""" Helper method to test course subjects. """
for subject in self.wrapped_course_run.subjects:
self.assertContains(response, subject.name)
class ChangeStateViewTests(TestCase):
""" Tests for the `ChangeStateView`. """
def setUp(self):
super(ChangeStateViewTests, self).setUp()
self.course = factories.CourseFactory()
self.course_run = factories.CourseRunFactory(course=self.course)
self.page_url = reverse('publisher:publisher_course_run_detail', args=[self.course_run.id])
self.change_state_url = reverse('publisher:publisher_change_state', args=[self.course_run.id])
def test_detail_page_change_state(self):
""" Verify that we can change workflow state from detail page. """
response = self.client.get(self.page_url)
self.assertContains(response, 'Status:')
self.assertContains(response, State.DRAFT.title())
# change workflow state from `DRAFT` to `NEEDS_REVIEW`
response = self.client.post(self.change_state_url, data={'state': State.NEEDS_REVIEW}, follow=True)
# assert that state is changed to `NEEDS_REVIEW`
self.assertContains(response, State.NEEDS_REVIEW.title().replace('_', ' '))
def test_detail_page_change_state_not_allowed(self):
""" Verify that we can't change workflow state from `DRAFT` to `PUBLISHED`. """
response = self.client.get(self.page_url)
self.assertContains(response, 'Status:')
self.assertContains(response, State.DRAFT.title())
# change workflow state from `DRAFT` to `PUBLISHED`
response = self.client.post(self.change_state_url, data={'state': State.PUBLISHED}, follow=True)
# assert that state is not changed to `PUBLISHED`
self.assertNotContains(response, State.PUBLISHED.title())
self.assertContains(response, 'There was an error in changing state.')
......@@ -5,7 +5,7 @@ from unittest import mock
from django.test import TestCase
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory
from course_discovery.apps.publisher.models import Seat
from course_discovery.apps.publisher.models import Seat, State
from course_discovery.apps.publisher.tests import factories
from course_discovery.apps.publisher.wrappers import CourseRunWrapper
......@@ -96,3 +96,7 @@ class CourseRunWrapperTests(TestCase):
wrapped_course_run = CourseRunWrapper(self.course_run)
self.assertEqual(wrapped_course_run.credit_seat, seat)
def test_workflow_state(self):
""" Verify that the wrapper can return workflow state. """
self.assertEqual(self.wrapped_course_run.workflow_state, State.DRAFT.title())
......@@ -13,6 +13,11 @@ urlpatterns = [
url(r'^course_runs/new$', views.CreateCourseRunView.as_view(), name='publisher_course_runs_new'),
url(r'^course_runs/(?P<pk>\d+)/$', views.CourseRunDetailView.as_view(), name='publisher_course_run_detail'),
url(r'^course_runs/(?P<pk>\d+)/edit/$', views.UpdateCourseRunView.as_view(), name='publisher_course_runs_edit'),
url(
r'^course_runs/(?P<course_run_id>\d+)/change_state/$',
views.ChangeStateView.as_view(),
name='publisher_change_state'
),
url(r'^seats/new$', views.CreateSeatView.as_view(), name='publisher_seats_new'),
url(r'^seats/(?P<pk>\d+)/edit/$', views.UpdateSeatView.as_view(), name='publisher_seats_edit'),
]
"""
Course publisher views.
"""
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.views.generic import View
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView, UpdateView
from django.views.generic.list import ListView
from django_fsm import TransitionNotAllowed
from course_discovery.apps.publisher.forms import CourseForm, CourseRunForm, SeatForm
from course_discovery.apps.publisher.models import Course, CourseRun, Seat
from course_discovery.apps.publisher.wrappers import CourseRunWrapper
......@@ -87,8 +91,16 @@ class UpdateCourseRunView(UpdateView):
template_name = 'publisher/course_run_form.html'
success_url = 'publisher:publisher_course_runs_edit'
def get_context_data(self, **kwargs):
context = super(UpdateCourseRunView, self).get_context_data(**kwargs)
if not self.object:
self.object = self.get_object()
context['workflow_state'] = self.object.current_state
return context
def form_valid(self, form):
self.object = form.save()
self.object.change_state()
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
......@@ -133,3 +145,20 @@ class UpdateSeatView(UpdateView):
def get_success_url(self):
return reverse(self.success_url, kwargs={'pk': self.object.id})
class ChangeStateView(View):
""" Change Workflow State View"""
def post(self, request, course_run_id):
state = request.POST.get('state')
try:
course_run = CourseRun.objects.get(id=course_run_id)
course_run.change_state(target=state)
messages.success(
request, 'Content moved to `{state}` successfully.'.format(state=course_run.current_state),
)
return HttpResponseRedirect(reverse('publisher:publisher_course_run_detail', kwargs={'pk': course_run_id}))
except (CourseRun.DoesNotExist, TransitionNotAllowed):
messages.error(request, 'There was an error in changing state.')
return HttpResponseRedirect(reverse('publisher:publisher_course_run_detail', kwargs={'pk': course_run_id}))
"""Publisher Wrapper Classes"""
from course_discovery.apps.publisher.models import Seat
from django.utils.translation import ugettext_lazy as _
from course_discovery.apps.publisher.models import Seat, State
CHANGE_STATE_BUTTON_VALUES = {
State.DRAFT: {'value': State.NEEDS_REVIEW, 'text': _('Send For Review')},
State.NEEDS_REVIEW: {'value': State.NEEDS_FINAL_APPROVAL, 'text': _('Send For Final Approval')},
State.NEEDS_FINAL_APPROVAL: {'value': State.FINALIZED, 'text': _('Finalize')},
State.FINALIZED: {'value': State.PUBLISHED, 'text': _('Publish')}
}
class BaseWrapper(object):
......@@ -94,7 +102,7 @@ class CourseRunWrapper(BaseWrapper):
@property
def subject_names(self):
return ', '.join([subject.name for subject in self.subjects])
return ', '.join([subject.name for subject in self.subjects if subject])
@property
def course_type(self):
......@@ -115,3 +123,11 @@ class CourseRunWrapper(BaseWrapper):
if not organizations:
return None
return organizations[0].key
@property
def workflow_state(self):
return self.wrapped_obj.current_state
@property
def change_state_button(self):
return CHANGE_STATE_BUTTON_VALUES.get(self.wrapped_obj.state.name)
......@@ -42,6 +42,7 @@ THIRD_PARTY_APPS = [
'dry_rest_permissions',
'compressor',
'django_filters',
'django_fsm',
]
PROJECT_APPS = [
......
$(".container > button").click(function(event) {
$(".administration-navbar .container > button").click(function(event) {
event.preventDefault();
$(this).addClass("selected");
$(this).siblings().removeClass("selected");
var tab = $(this).data("tab");
$(".tab-content").not(tab).css("display", "none");
$(tab).fadeIn();
});
function alertTimeout(wait) {
setTimeout(function(){
$('.alert-messages').html('');
}, wait);
}
\ No newline at end of file
// ------------------------------
// // edX Course Discovery: Base
// About: Base resets and definitons (using shared resources from elements when appropriate).
// // edX Course Discovery: Course Detail
// ------------------------------
// #RESET
// ------------------------------
//
//
//// ------------------------------
// Fontawesome icons
// ------------------------------
* {
margin: 0;
padding: 0;
text-decoration: none;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
body {
color: #434242;
background: #fafafa;
font-family: "Open Sans";
line-height: 1.42857;
}
// ------------------------------
// #BASE
// ------------------------------
html, body {
height: 100%;
margin: 0;
padding: 0;
background: #fafafa;
}
// ------------------------------
// #TYPOGRAPHY
......@@ -50,6 +16,10 @@ html, body {
width: 1170px;
margin: auto;
overflow: auto;
.actions {
margin-top: 30px;
}
}
nav {
......@@ -86,7 +56,11 @@ nav {
padding: 0 30px 30px;
}
.course-information {
.alert-messages .alert {
margin-bottom: 20px;
}
.course-information, .status-information {
margin-bottom: 30px;
.info-item {
......@@ -108,7 +82,12 @@ nav {
}
}
.btn-edit {
@include float(right);
@include margin-right(30px);
@include padding-left(20px);
@include padding-right(20px);
}
.breadcrumb {
padding: 8px 15px;
......
......@@ -25,6 +25,33 @@
<span class="course-name">{{ object.title }}</span>
</h2>
</div>
<div class="alert-messages">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}" role="alert" aria-labelledby="alert-title-{{ message.tags }}" tabindex="-1">
<div class="alert-message-with-action">
<p class="alert-copy">
{{ message }}
</p>
</div>
</div>
{% endfor %}
{% endif %}
</div>
<div class="status-information">
<div class="info-item">
<span class="item">
<span class="heading">{% trans "Status" %}:</span>
<span>{{ object.workflow_state }}</span>
</span>
<span class="item">
<a href="{% url 'publisher:publisher_course_runs_edit' pk=object.id %}" target="_blank" class="btn btn-small btn-edit">
<span class="icon fa fa-edit" aria-hidden="true"></span>&nbsp;&nbsp;{% trans "Edit" %}
</a>
</span>
</div>
</div>
<div class="tab">
<div id="tab-1" class="tab-content active">
......@@ -40,6 +67,12 @@
{% include 'publisher/course_run_detail/_drupal.html' %}
</div>
</div>
<div class="actions">
<form action="{% url 'publisher:publisher_change_state' course_run_id=object.id %}" method="post"> {% csrf_token %}
<button type="submit" value="{{ object.change_state_button.value }}" class="btn-brand btn-small btn-states" name="state">{{ object.change_state_button.text }}</button>
</form>
</div>
</div>
{% endblock %}
......@@ -52,5 +85,7 @@
return $(trigger).parent().next('.copy').text().trim();
}
});
alertTimeout(5000);
</script>
{% endblock %}
......@@ -8,11 +8,22 @@
<div class="layout-full layout">
<div class="card course-form">
<h4 class="hd-4">{% trans "Course Run Form" %}</h4>
{% if object.id %}
<a href="{% url 'publisher:publisher_seats_new' %}" target="_blank" class="btn btn-neutral btn-add">
{% trans "Add Seat" %}
</a>
{% endif %}
<div class="status-information">
{% if object.id %}
<div class="info-item">
<span class="item">
<span class="heading">{% trans "Status" %}:</span>
<span>{{ workflow_state }}</span>
</span>
<span class="item">
<a href="{% url 'publisher:publisher_seats_new' %}" target="_blank" class="btn btn-neutral btn-add">
{% trans "Add Seat" %}
</a>
</span>
</div>
{% endif %}
</div>
<form class="form" method="post" action=""> {% csrf_token %}
<fieldset class="form-group">
{% for field in form %}
......
......@@ -15,18 +15,20 @@
<th>{% trans "Partner" %}</th>
<th>{% trans "Target Content?" %}</th>
<th>{% trans "Priority" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Last Updated" %}</th>
</tr>
{% for course_run in object_list %}
{% url 'publisher:publisher_course_run_detail' course_run.id as detail_url %}
<tr>
<td>
<a href={{detail_url}}>{{ course_run.title }}</a>
<a target="_blank" href="{{ detail_url }}">{{ course_run.title }}</a>
</td>
<td>{{ course_run.start }}</td>
<td>{{ course_run.partner }}</td>
<td>{{ course_run.target_content }}</td>
<td>{{ course_run.priority }}</td>
<td>{{ course_run.workflow_state }}</td>
<td>{{ course_run.modified }}</td>
</tr>
{% endfor %}
......
......@@ -4,6 +4,7 @@ django-choices==1.4.3
django-compressor==2.0
django-extensions==1.6.7
django-filter==0.13.0
django-fsm==2.2.0
django-guardian==1.4.4
django-haystack==2.4.1
django-libsass==0.7
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment