Kaynağa Gözat

Add support for flash messages to navbar template.
Add extra instance view support to CRUD viewsets.
Add starting_month to FeedCategory.
Update RSS feed day number calculation to take starting month and days
required into account.
Make import_from_excel management command atomic.
Add import from excel view to feed category.

Andrew Klopper 8 yıl önce
ebeveyn
işleme
501eef3e30

+ 8 - 0
base/templates/layouts/navbar.html

@@ -53,6 +53,14 @@
53 53
 </nav>
54 54
 
55 55
 <div class="container">
56
+  {% block messages %}
57
+    {% for message in messages %}
58
+      <div class="alert alert-dismissable{% if message.tags %} alert-{{ message.tags }}{% endif %}" role="alert">
59
+        <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
60
+        {{ message }}
61
+      </div>
62
+    {% endfor %}
63
+  {% endblock %}
56 64
   {% block container_content %}{% endblock %}
57 65
 </div>
58 66
 {% endblock body_content %}

+ 54 - 13
crud/viewsets.py

@@ -53,6 +53,8 @@ class CrudViewset(object):
53 53
     instance_breadcrumb_text = u"{0}"
54 54
     instance_breadcrumb_view_type = DETAIL_VIEW_TYPE
55 55
 
56
+    extra_instance_views = []
57
+
56 58
     lookup_field = 'pk'
57 59
     lookup_url_kwarg = 'pk'
58 60
     lookup_url_kwarg_pattern = r'\d+'
@@ -140,6 +142,8 @@ class CrudViewset(object):
140 142
 
141 143
         self.base_view_mixin = None
142 144
         self.views = {}
145
+        self.instance_view_types = set(INSTANCE_VIEW_TYPES)
146
+        self.extra_instance_view_types = set()
143 147
 
144 148
         self.exclude_views = set(self.exclude_views)
145 149
         if self.is_list_view_nested:
@@ -173,6 +177,7 @@ class CrudViewset(object):
173 177
         self.generate_detail_view()
174 178
         self.generate_update_view()
175 179
         self.generate_delete_view()
180
+        self.generate_extra_instance_views()
176 181
 
177 182
         self.is_view_enabled = {view_type: view is not None for view_type, view in self.views.items()}
178 183
 
@@ -235,7 +240,7 @@ class CrudViewset(object):
235 240
 
236 241
     def get_view_url_kwargs(self, view_type, view, instance=None):
237 242
         kwargs = {name: view.kwargs[name] for name in self.view_url_kwarg_names}
238
-        if view_type in INSTANCE_VIEW_TYPES:
243
+        if view_type in self.instance_view_types:
239 244
             if isinstance(instance, dict):
240 245
                 kwargs[self.lookup_url_kwarg] = instance.get(self.lookup_field)
241 246
             else:
@@ -275,7 +280,7 @@ class CrudViewset(object):
275 280
         if not self.is_list_view_nested and self.is_view_enabled[LIST_VIEW_TYPE]:
276 281
             breadcrumbs.append(self.get_list_view_breadcrumb(view, GET, instance))
277 282
 
278
-        if view_type in INSTANCE_VIEW_TYPES:
283
+        if view_type in self.instance_view_types:
279 284
             breadcrumbs += [{
280 285
                 'text': self.get_instance_breadcrumb_text(view, instance),
281 286
                 'url': self.get_view_url(self.instance_breadcrumb_view_type, view, GET, instance)
@@ -437,14 +442,13 @@ class CrudViewset(object):
437 442
             class BaseViewMixin(object):
438 443
                 viewset = self
439 444
                 view_type = None
440
-                final_breadcrumb = None
441 445
                 pk_url_kwarg = viewset.lookup_url_kwarg
442 446
 
443 447
                 def get_queryset(self):
444 448
                     return self.viewset.get_queryset(self)
445 449
 
446
-                def get_view_title(self):
447
-                    raise NotImplementedError
450
+                # Derived classes must inherit or define
451
+                # def get_view_title(self)
448 452
 
449 453
                 def get_cached_object(self, viewset=None):
450 454
                     return self.cached_objects[self.viewset if viewset is None else viewset]
@@ -473,10 +477,11 @@ class CrudViewset(object):
473 477
                     viewset = self.viewset
474 478
 
475 479
                     breadcrumbs = viewset.get_breadcrumbs(self.view_type, self, self.request.GET)
476
-                    if self.final_breadcrumb is not None:
480
+                    final_breadcrumb = getattr(self, 'final_breadcrumb', None)
481
+                    if final_breadcrumb is not None:
477 482
                         breadcrumbs.append(
478 483
                             {
479
-                                'text': self.final_breadcrumb,
484
+                                'text': final_breadcrumb,
480 485
                                 # Includes the query string.
481 486
                                 'url': self.request.get_full_path()
482 487
                             }
@@ -489,13 +494,20 @@ class CrudViewset(object):
489 494
                     context['is_view_enabled'] = viewset.is_view_enabled
490 495
                     context['actions'] = []
491 496
 
492
-                    if (self.view_type != LIST_VIEW_TYPE) and (viewset.is_list_view_nested or viewset.is_view_enabled[LIST_VIEW_TYPE]):
497
+                    if (self.view_type in viewset.extra_instance_view_types):
498
+                        back_url = viewset.get_view_url(self.viewset.instance_breadcrumb_view_type, self, self.request.GET, self.get_object())
499
+                    elif ((self.view_type != LIST_VIEW_TYPE) and (viewset.is_list_view_nested or viewset.is_view_enabled[LIST_VIEW_TYPE])):
500
+                        back_url = viewset.get_view_url(LIST_VIEW_TYPE, self, self.request.GET)
501
+                    else:
502
+                        back_url = None
503
+
504
+                    if back_url is not None:
493 505
                         context['actions'].append(
494 506
                             [
495 507
                                 {
496 508
                                     'type': 'button',
497 509
                                     'text': viewset.get_back_button_text(),
498
-                                    'url': viewset.get_view_url(LIST_VIEW_TYPE, self, self.request.GET),
510
+                                    'url': back_url,
499 511
                                     'button_class': viewset.get_back_button_class()
500 512
                                 }
501 513
                             ]
@@ -640,7 +652,7 @@ class CrudViewset(object):
640 652
                             {
641 653
                                 'type': 'button',
642 654
                                 'id': 'id_edit_button',
643
-                                'url': viewset.get_view_url(UPDATE_VIEW_TYPE, self, self.request.GET, self.get_cached_object()),
655
+                                'url': viewset.get_view_url(UPDATE_VIEW_TYPE, self, self.request.GET, self.get_object()),
644 656
                                 'text': edit_button_text,
645 657
                                 'button_class': viewset.get_edit_button_class()
646 658
                             }
@@ -653,14 +665,16 @@ class CrudViewset(object):
653 665
                             {
654 666
                                 'type': 'button',
655 667
                                 'id': 'id_delete_button',
656
-                                'url': viewset.get_view_url(DELETE_VIEW_TYPE, self, self.request.GET, self.get_cached_object()),
668
+                                'url': viewset.get_view_url(DELETE_VIEW_TYPE, self, self.request.GET, self.get_object()),
657 669
                                 'text': delete_button_text,
658 670
                                 'button_class': viewset.get_delete_button_class()
659 671
                             }
660 672
                         )
661 673
 
662 674
                 if action_group:
663
-                    context['actions'].append(action_group)
675
+                    # HACK: Insert after the back button and before any other
676
+                    # action groups.
677
+                    context['actions'].insert(min(1, len(context['actions'])), action_group)
664 678
 
665 679
                 context['nested_list_views'] = []
666 680
                 if self.viewset.nested_list_views:
@@ -717,7 +731,7 @@ class CrudViewset(object):
717 731
                             [
718 732
                                 {
719 733
                                     'type': 'button',
720
-                                    'url': viewset.get_view_url(DELETE_VIEW_TYPE, self, self.request.GET, self.get_cached_object()),
734
+                                    'url': viewset.get_view_url(DELETE_VIEW_TYPE, self, self.request.GET, self.get_object()),
721 735
                                     'text': delete_button_text,
722 736
                                     'button_class': viewset.get_delete_button_class()
723 737
                                 }
@@ -775,6 +789,29 @@ class CrudViewset(object):
775 789
 
776 790
         self.views[DELETE_VIEW_TYPE] = self.get_decorated_view(DeleteView)
777 791
 
792
+    def generate_extra_instance_views(self):
793
+        for instance_view in self.extra_instance_views:
794
+            my_view_type = instance_view['view_type']
795
+            if self.is_view_generated(my_view_type):
796
+                continue
797
+
798
+            class InstanceView(self.get_base_view_mixin(), instance_view['view_class']):
799
+                viewset = self
800
+                view_type = my_view_type
801
+
802
+                def dispatch(self, request, *args, **kwargs):
803
+                    self.populate_object_cache(self.viewset.get_object(self))
804
+                    return super(InstanceView, self).dispatch(request, *args, **kwargs)
805
+
806
+                def get_context_data(self, *args, **kwargs):
807
+                    context = super(InstanceView, self).get_context_data(*args, **kwargs)
808
+                    context['object'] = self.get_object()
809
+                    return context
810
+
811
+            self.views[my_view_type] = self.get_decorated_view(InstanceView)
812
+            self.instance_view_types.add(my_view_type)
813
+            self.extra_instance_view_types.add(my_view_type)
814
+
778 815
     @classmethod
779 816
     def urls(cls):
780 817
         viewset = cls()
@@ -794,6 +831,10 @@ class CrudViewset(object):
794 831
         append_pattern(lookup_regex + 'update/$', UPDATE_VIEW_TYPE)
795 832
         append_pattern(lookup_regex + 'delete/$', DELETE_VIEW_TYPE)
796 833
 
834
+        for instance_view in viewset.extra_instance_views:
835
+            view_type = instance_view['view_type']
836
+            patterns.append(url(lookup_regex + instance_view['regex'], viewset.views[view_type], name=view_type))
837
+
797 838
         if not patterns:
798 839
             # If a viewset functions only as a nested list view then it will
799 840
             # not define any views of its own. We need at least one view URL

+ 9 - 7
feed_content/management/commands/import_from_excel.py

@@ -1,4 +1,5 @@
1 1
 from django.core.management.base import BaseCommand, CommandError
2
+from django.db import transaction
2 3
 import xlrd
3 4
 from ... import models
4 5
 from pick import pick
@@ -57,13 +58,14 @@ def import_sheet(sheet, category):
57 58
     if index == 0:
58 59
         return
59 60
 
60
-    models.FeedItem.objects.filter(feed_category=category).delete()
61
-    for feed_item in feed_items:
62
-        models.FeedItem.objects.create(
63
-            feed_category=category,
64
-            day_number=feed_item['day'],
65
-            message_text=feed_item['message_text']
66
-        )
61
+    with transaction.atomic():
62
+        models.FeedItem.objects.filter(feed_category=category).delete()
63
+        for feed_item in feed_items:
64
+            models.FeedItem.objects.create(
65
+                feed_category=category,
66
+                day_number=feed_item['day'],
67
+                message_text=feed_item['message_text']
68
+            )
67 69
 
68 70
 class Command(BaseCommand):
69 71
     help = 'Imports feed items from an Excel spreedsheet.'

+ 20 - 0
feed_content/migrations/0003_feedcategory_starting_month.py

@@ -0,0 +1,20 @@
1
+# -*- coding: utf-8 -*-
2
+# Generated by Django 1.11.1 on 2017-06-02 14:44
3
+from __future__ import unicode_literals
4
+
5
+from django.db import migrations, models
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        ('feed_content', '0002_feedcategory_days_required'),
12
+    ]
13
+
14
+    operations = [
15
+        migrations.AddField(
16
+            model_name='feedcategory',
17
+            name='starting_month',
18
+            field=models.PositiveIntegerField(choices=[(1, 'Jan'), (2, 'Feb'), (3, 'Mar'), (4, 'Apr'), (5, 'May'), (6, 'Jun'), (7, 'Jul'), (8, 'Aug'), (9, 'Sep'), (10, 'Oct'), (11, 'Nov'), (12, 'Dec')], default=1),
19
+        ),
20
+    ]

+ 17 - 0
feed_content/models.py

@@ -34,6 +34,23 @@ class FeedCategory(ModelEx):
34 34
     name = models.CharField(max_length=50, unique=True)
35 35
     description = models.CharField(max_length=255, blank=True)
36 36
     days_required = models.PositiveIntegerField(default=366)
37
+    starting_month = models.PositiveIntegerField(
38
+        default=1,
39
+        choices=(
40
+            (1, 'January'),
41
+            (2, 'February'),
42
+            (3, 'March'),
43
+            (4, 'April'),
44
+            (5, 'May'),
45
+            (6, 'June'),
46
+            (7, 'July'),
47
+            (8, 'August'),
48
+            (9, 'September'),
49
+            (10, 'October'),
50
+            (11, 'November'),
51
+            (12, 'December')
52
+        )
53
+    )
37 54
 
38 55
     class Meta:
39 56
         verbose_name = 'Category'

+ 4 - 0
feed_content/templates/feed_content/feed_category_detail_view.html

@@ -10,6 +10,10 @@
10 10
   <div class="col-xs-10">{{ object.description }}</div>
11 11
 </div>
12 12
 <div class="row">
13
+  <div class="col-xs-2"><label>Starting Month</label></div>
14
+  <div class="col-xs-10">{{ object.get_starting_month_display }}</div>
15
+</div>
16
+<div class="row">
13 17
   <div class="col-xs-2"><label>Days Required</label></div>
14 18
   <div class="col-xs-10">{{ object.days_required }}</div>
15 19
 </div>

+ 249 - 0
feed_content/templates/feed_content/feed_category_import_excel.html

@@ -0,0 +1,249 @@
1
+{% extends "crud/base.html" %}
2
+
3
+{% block cdn_js %}
4
+{{ block.super }}
5
+<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.10.3/xlsx.full.min.js"></script>
6
+{% endblock %}
7
+
8
+{% block crud_view_css %}
9
+{{ block.super }}
10
+<style>
11
+  .sheet-required {
12
+    display: none;
13
+  }
14
+  #import_form {
15
+    display: none;
16
+  }
17
+</style>
18
+{% endblock %}
19
+
20
+{% block crud_view_content %}
21
+{{ block.super }}
22
+<div class="row">
23
+  <div class="col-xs-2"><label>Select workbook</label></div>
24
+  <div class="col-xs-10"><input type="file" id="file_input"></div>
25
+</div>
26
+<div class="row sheet-required">
27
+  <div class="col-xs-2"><label>Select worksheet</label></div>
28
+  <div class="col-xs-10"><select id="sheet_name_input"></select></div>
29
+</div>
30
+<div class="sheet-required">
31
+  <br>
32
+  <div id="sheet_status"></div>
33
+
34
+  <button type="button" class="btn btn-primary" id="import_button" data-toggle="modal" data-target="#confirm_import_modal">Import</button>
35
+
36
+  <h2><span class="sheet-name"></span> <span class="glyphicon glyphicon-arrow-right"></span> {{ object.name }}</h2>
37
+
38
+  <div id="sheet_contents"></div>
39
+
40
+  <div class="modal modal-fade" id="confirm_import_modal" tabindex="-1" role="dialog" aria-labelled-by="confirm_import_modal_title">
41
+    <div class="modal-dialog" role="document">
42
+      <div class="modal-content">
43
+        <div class="modal-header">
44
+          <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
45
+          <h4 class="modal-title" id="confirm_import_modal_title">Confirm import</h4>
46
+        </div>
47
+        <div class="modal-body">
48
+          <div class="container-responsive">
49
+            <div class="row">
50
+              <div class="col-md-4"><label>Source file</label></div>
51
+              <div class="col-md-8"><span class="workbook-name"></span></div>
52
+            </div>
53
+            <div class="row">
54
+              <div class="col-md-4"><label>Worksheet</label></div>
55
+              <div class="col-md-8"><span class="sheet-name"></span></div>
56
+            </div>
57
+            <div class="row">
58
+              <div class="col-md-4"><label>Destination category</label></div>
59
+              <div class="col-md-8">{{ object.name }}</div>
60
+            </div>
61
+            <div class="row">
62
+              <div class="col-md-4"><label>Number of days</label></div>
63
+              <div class="col-md-8"><span class="num-messages"></span></div>
64
+            </div>
65
+            <br>
66
+            <div class="alert alert-danger">
67
+              <strong>WARNING:</strong> All existing messages in the category will be removed and replaced with the imported messages.
68
+            </div>
69
+          </div>
70
+        </div>
71
+        <div class="modal-footer">
72
+          <button type="button" class="btn btn-primary" id="confirm_import_button">Confirm import</button>
73
+          <button type="button" class="btn btn-danger" data-dismiss="modal">Cancel</button>
74
+        </div>
75
+      </div>
76
+    </div>
77
+  </div>
78
+
79
+  <form action="" method="POST" id="import_form">
80
+    {% csrf_token %}
81
+    <input type="hidden" name="messages_json" id="messages_json">
82
+  </form>
83
+</div>
84
+{% endblock %}
85
+
86
+{% block crud_view_js %}
87
+{{ block.super }}
88
+<script>
89
+  'use strict';
90
+
91
+  var workbook = null;
92
+  var messages_to_import = null;
93
+
94
+  function set_status_error(message_html) {
95
+    messages_to_import = null;
96
+    $('#sheet_status').attr('class', 'alert alert-danger').html(message_html);
97
+    $('#import_button').prop('disabled', true);
98
+  }
99
+
100
+  function set_status_success(message_html) {
101
+    $('#sheet_status').attr('class', 'alert alert-success').html(message_html);
102
+    $('#import_button').prop('disabled', false);
103
+  }
104
+
105
+  function reset_workbook() {
106
+    workbook = null;
107
+    messages_to_import = null;
108
+    $('.sheet-required').hide();
109
+    $('.workbook-name').text('');
110
+    $('.sheet-name').text('');
111
+    $('.num-messages').text('');
112
+  }
113
+
114
+  function workbook_changed() {
115
+    try {
116
+      var sheet_name_input = $('#sheet_name_input');
117
+      sheet_name_input.empty();
118
+      workbook.SheetNames.forEach(function(name, index) {
119
+        sheet_name_input.append($('<option/>').val(index).text(name));
120
+      });
121
+      $('#sheet_name_input').change();
122
+      $('.sheet-required').show();
123
+    }
124
+    catch (err) {
125
+      reset_workbook();
126
+      alert("Error reading worksheet names: " + err);
127
+    }
128
+  }
129
+
130
+  function sheet_changed(index) {
131
+    try {
132
+      var sheet_name = workbook.SheetNames[index];
133
+      var sheet = workbook.Sheets[sheet_name];
134
+      $('.sheet-name').text(sheet_name);
135
+      $('#sheet_contents').html(XLSX.utils.sheet_to_html(sheet));
136
+      $('#sheet_contents table').addClass('table table-bordered table-striped table-hover');
137
+      var sheet_range = XLSX.utils.decode_range(sheet['!ref']);
138
+      var start_row = null;
139
+      var start_col = null;
140
+      var max_row = Math.min(sheet_range.e.r, sheet_range.s.r + 19);
141
+      for (var row = sheet_range.s.r; row <= max_row; row++) {
142
+        for (var col = sheet_range.s.c; col <= sheet_range.e.c; col++) {
143
+          var cell = sheet[XLSX.utils.encode_cell({r: row, c: col})];
144
+          if ((cell != null) && (cell.t == 's') && (cell.v.trim().toLowerCase() == 'day')) {
145
+            start_row = row + 1;
146
+            start_col = col;
147
+            break;
148
+          }
149
+        }
150
+      }
151
+      if (start_row == null) {
152
+        set_status_error('The worksheet does not contain a <strong>Day</strong> column in the first 20 rows.<br>Please select a different worksheet.');
153
+      }
154
+      else if (start_row > sheet_range.e.r) {
155
+        set_status_error('The <strong>Day</strong> column does not contain any data.');
156
+      }
157
+      else if (start_col >= sheet_range.e.c) {
158
+        set_status_error('There is no message text column to the right of the <strong>Day</strong> column.');
159
+      }
160
+      else {
161
+        var expected_day = 1;
162
+        var end_row = null;
163
+        var messages = [];
164
+        for (var row = start_row; row <= sheet_range.e.r; row++) {
165
+          var cell_address = XLSX.utils.encode_cell({r: row, c: start_col});
166
+          var cell = sheet[cell_address];
167
+          if ((cell == null) || (cell.t != 'n')) {
168
+            break;
169
+          }
170
+          var day = cell.v;
171
+          if (day != expected_day) {
172
+            set_status_error('Found day ' + day + ' instead of ' + expected_day + ' in cell ' + cell_address + '.');
173
+            return;
174
+          }
175
+
176
+          cell_address = XLSX.utils.encode_cell({r: row, c: start_col + 1});
177
+          cell = sheet[cell_address];
178
+          var message_text = (cell != null) && (cell.t == 's') ? cell.v.trim() : '';
179
+          if (message_text == '') {
180
+            set_status_error('No message text found for day ' + day + ' in cell ' + cell_address);
181
+            return;
182
+          }
183
+
184
+          messages.push({day: day, message_text: message_text});
185
+          expected_day++;
186
+          end_row = row;
187
+        }
188
+
189
+        if (end_row < sheet_range.e.r) {
190
+          var cell_address = XLSX.utils.encode_cell({r: end_row + 1, c: start_col});
191
+          var cell = sheet[cell_address];
192
+          if (cell != null) {
193
+            set_status_error('Found a non-numerical value "' + cell.v + '" in the day column in cell ' + cell_address + '.');
194
+            return;
195
+          }
196
+        }
197
+
198
+        messages_to_import = messages;
199
+        set_status_success('<p>Ready to import <strong>' + messages.length + '</strong> messages.</p><p><strong>WARNING:</strong> All existing messages in the category will be removed and replaced with the imported messages.</p><p>Click the <strong>Import</strong> button to continue.</p>');
200
+        $('.num-messages').text(messages.length);
201
+      }
202
+    }
203
+    catch (err) {
204
+      reset_workbook();
205
+      alert("Error reading worksheet: " + err);
206
+    }
207
+  }
208
+
209
+  function submit_imported_messages() {
210
+    if (messages_to_import == null) {
211
+      alert('There are no messages to import.');
212
+      return;
213
+    }
214
+    $('#messages_json').val(JSON.stringify(messages_to_import));
215
+    $('#import_form').submit();
216
+  }
217
+
218
+  $('#file_input').change(function(e) {
219
+    var file = this.files[0];
220
+    if (file) {
221
+      $('.workbook-name').text(file.name);
222
+      var reader = new FileReader();
223
+      reader.readAsBinaryString(file);
224
+      reader.onload = function(e) {
225
+        try {
226
+          workbook = XLSX.read(e.target.result, {type: 'binary'});
227
+          workbook_changed();
228
+        }
229
+        catch (err) {
230
+          reset_workbook();
231
+          alert("Error loading workbook: " + err);
232
+        }
233
+      };
234
+      reader.onerror = function(e) {
235
+        reset_workbook();
236
+        alert("Error reading file");
237
+      };
238
+    }
239
+  });
240
+
241
+  $('#sheet_name_input').change(function() {
242
+    sheet_changed($(this).val());
243
+  });
244
+
245
+  $('#confirm_import_button').click(function() {
246
+    submit_imported_messages();
247
+  });
248
+</script>
249
+{% endblock %}

+ 56 - 2
feed_content/views.py

@@ -1,6 +1,60 @@
1 1
 # -*- coding: utf-8 -*-
2 2
 from __future__ import unicode_literals
3 3
 
4
-from django.shortcuts import render
4
+from django.shortcuts import render, redirect
5
+from django.views.generic import TemplateView
6
+from django.contrib import messages as flash_messages
7
+from django.db import transaction
8
+from . import models
9
+import json
5 10
 
6
-# Create your views here.
11
+#import logging
12
+#logger = logging.getLogger('console')
13
+
14
+class FeedCategoryImportExcelView(TemplateView):
15
+    template_name = 'feed_content/feed_category_import_excel.html'
16
+    final_breadcrumb = 'Import'
17
+
18
+    def get_view_title(self):
19
+        return 'Import {0} from Excel'.format(self.get_object().name)
20
+
21
+    def post(self, request, *args, **kwargs):
22
+        try:
23
+            viewset = self.viewset
24
+            category = self.get_object()
25
+
26
+            messages_json = request.POST.get('messages_json', '').strip()
27
+            if messages_json == '':
28
+                raise RuntimeError('No import data was specified.')
29
+
30
+            messages = json.loads(messages_json)
31
+            messages_to_import = []
32
+            expected_day = 1
33
+            for message in messages:
34
+                day = int(message['day'])
35
+                message_text = message['message_text'].strip()
36
+                if day != expected_day:
37
+                    raise RuntimeError('Expected day {0} but got day {1}.'.format(expected_day, day))
38
+                if message_text == '':
39
+                    raise RuntimeError('Message for day {0} is blank.'.format(day))
40
+                messages_to_import.append({'day': day, 'message_text': message_text})
41
+                expected_day += 1
42
+
43
+            if not messages_to_import:
44
+                raise RuntimeError('No import data was specified.')
45
+
46
+            with transaction.atomic():
47
+                models.FeedItem.objects.filter(feed_category=category).delete()
48
+                for message in messages_to_import:
49
+                    models.FeedItem.objects.create(
50
+                        feed_category=category,
51
+                        day_number=message['day'],
52
+                        message_text=message['message_text']
53
+                    )
54
+
55
+            flash_messages.add_message(request, flash_messages.SUCCESS, 'Import succeeded.');
56
+
57
+        except Exception as e:
58
+            flash_messages.add_message(request, flash_messages.ERROR, 'IMPORT FAILED: {0}'.format(e.message))
59
+
60
+        return redirect(viewset.get_view_url(viewset.instance_breadcrumb_view_type, self, request.GET, self.get_object()))

+ 24 - 3
feed_content/viewsets.py

@@ -6,7 +6,7 @@ from django_tables2 import Column, LinkColumn, A
6 6
 from django.shortcuts import reverse
7 7
 from django.utils.text import mark_safe
8 8
 import django_filters
9
-from . import models
9
+from . import models, views
10 10
 
11 11
 class FeedCategoryFilterSet(django_filters.FilterSet):
12 12
     search_expr = django_filters.CharFilter(label='Search', method='filter_search_expr')
@@ -24,7 +24,7 @@ class FeedCategoryTable(BaseTable):
24 24
     delete = CrudLinkColumn('delete', text='', verbose_name='', orderable=False, attrs={'a': {'class': 'btn btn-danger btn-xs glyphicon glyphicon-remove', 'role': 'button'}})
25 25
 
26 26
     class Meta(BaseTable.Meta):
27
-        fields = ('name', 'description', 'rss_url', 'days_required', 'status', 'delete')
27
+        fields = ('name', 'description', 'rss_url', 'starting_month', 'days_required', 'status', 'delete')
28 28
         order_by = ('name',)
29 29
 
30 30
     def render_rss_url(self, value, bound_column):
@@ -36,7 +36,7 @@ class FeedCategoryForm(forms.ModelForm):
36 36
 
37 37
     class Meta:
38 38
         model = models.FeedCategory
39
-        fields = ('name', 'description', 'days_required')
39
+        fields = ('name', 'description', 'starting_month', 'days_required')
40 40
 
41 41
 class FeedCategoryViewset(BaseViewset):
42 42
     lookup_url_kwarg = 'feed_category_id'
@@ -49,9 +49,30 @@ class FeedCategoryViewset(BaseViewset):
49 49
     detail_view_title = 'Category'
50 50
     detail_view_template_name = 'feed_content/feed_category_detail_view.html'
51 51
 
52
+    extra_instance_views = [
53
+        {
54
+            'view_type': 'import_excel',
55
+            'regex': r'import_excel/$',
56
+            'view_class': views.FeedCategoryImportExcelView
57
+        }
58
+    ]
59
+
52 60
     def get_queryset(self, view):
53 61
         return models.FeedCategory.objects.all()
54 62
 
63
+    def add_context_data(self, view, context):
64
+        super(FeedCategoryViewset, self).add_context_data(view, context)
65
+        if view.view_type == 'detail':
66
+            context['actions'].append(
67
+                [
68
+                    {
69
+                        'type': 'button',
70
+                        'text': 'Import from Excel',
71
+                        'url': self.get_view_url('import_excel', view, view.request.GET, view.get_object()),
72
+                        'button_class': 'btn-default'
73
+                    }
74
+                ]
75
+            )
55 76
 
56 77
 class FeedItemFilterSet(django_filters.FilterSet):
57 78
     day_number = django_filters.NumberFilter(label='Day')

+ 4 - 2
rss/feeds.py

@@ -21,10 +21,12 @@ class DailyFeed(Feed):
21 21
 
22 22
     def items(self, obj):
23 23
         now = datetime.now()
24
-        day_of_year = now.timetuple().tm_yday
24
+        now_tuple = now.timetuple()
25
+        starting_date = datetime(now_tuple.tm_year if now_tuple.tm_mon >= obj.starting_month else now_tuple.tm_year - 1, obj.starting_month, 1)
26
+        day_number = ((now - starting_date).days + 1) % obj.days_required
25 27
 
26 28
         try:
27
-            feed_item = FeedItem.objects.get(feed_category=obj, day_number=day_of_year)
29
+            feed_item = FeedItem.objects.get(feed_category=obj, day_number=day_number)
28 30
         except FeedItem.DoesNotExist:
29 31
             feed_item = None
30 32
 

+ 5 - 0
sms_feed/settings.py

@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/1.11/ref/settings/
11 11
 """
12 12
 
13 13
 import os
14
+from django.contrib.messages import constants as messages
14 15
 
15 16
 
16 17
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@@ -158,4 +159,8 @@ LOGGING = {
158 159
     }
159 160
 }
160 161
 
162
+MESSAGE_TAGS = {
163
+    messages.ERROR: 'danger'
164
+}
165
+
161 166
 from settings_local import *