Преглед изворни кода

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 година
родитељ
комит
501eef3e30

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

53
 </nav>
53
 </nav>
54
 
54
 
55
 <div class="container">
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
   {% block container_content %}{% endblock %}
64
   {% block container_content %}{% endblock %}
57
 </div>
65
 </div>
58
 {% endblock body_content %}
66
 {% endblock body_content %}

+ 54 - 13
crud/viewsets.py

53
     instance_breadcrumb_text = u"{0}"
53
     instance_breadcrumb_text = u"{0}"
54
     instance_breadcrumb_view_type = DETAIL_VIEW_TYPE
54
     instance_breadcrumb_view_type = DETAIL_VIEW_TYPE
55
 
55
 
56
+    extra_instance_views = []
57
+
56
     lookup_field = 'pk'
58
     lookup_field = 'pk'
57
     lookup_url_kwarg = 'pk'
59
     lookup_url_kwarg = 'pk'
58
     lookup_url_kwarg_pattern = r'\d+'
60
     lookup_url_kwarg_pattern = r'\d+'
140
 
142
 
141
         self.base_view_mixin = None
143
         self.base_view_mixin = None
142
         self.views = {}
144
         self.views = {}
145
+        self.instance_view_types = set(INSTANCE_VIEW_TYPES)
146
+        self.extra_instance_view_types = set()
143
 
147
 
144
         self.exclude_views = set(self.exclude_views)
148
         self.exclude_views = set(self.exclude_views)
145
         if self.is_list_view_nested:
149
         if self.is_list_view_nested:
173
         self.generate_detail_view()
177
         self.generate_detail_view()
174
         self.generate_update_view()
178
         self.generate_update_view()
175
         self.generate_delete_view()
179
         self.generate_delete_view()
180
+        self.generate_extra_instance_views()
176
 
181
 
177
         self.is_view_enabled = {view_type: view is not None for view_type, view in self.views.items()}
182
         self.is_view_enabled = {view_type: view is not None for view_type, view in self.views.items()}
178
 
183
 
235
 
240
 
236
     def get_view_url_kwargs(self, view_type, view, instance=None):
241
     def get_view_url_kwargs(self, view_type, view, instance=None):
237
         kwargs = {name: view.kwargs[name] for name in self.view_url_kwarg_names}
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
             if isinstance(instance, dict):
244
             if isinstance(instance, dict):
240
                 kwargs[self.lookup_url_kwarg] = instance.get(self.lookup_field)
245
                 kwargs[self.lookup_url_kwarg] = instance.get(self.lookup_field)
241
             else:
246
             else:
275
         if not self.is_list_view_nested and self.is_view_enabled[LIST_VIEW_TYPE]:
280
         if not self.is_list_view_nested and self.is_view_enabled[LIST_VIEW_TYPE]:
276
             breadcrumbs.append(self.get_list_view_breadcrumb(view, GET, instance))
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
             breadcrumbs += [{
284
             breadcrumbs += [{
280
                 'text': self.get_instance_breadcrumb_text(view, instance),
285
                 'text': self.get_instance_breadcrumb_text(view, instance),
281
                 'url': self.get_view_url(self.instance_breadcrumb_view_type, view, GET, instance)
286
                 'url': self.get_view_url(self.instance_breadcrumb_view_type, view, GET, instance)
437
             class BaseViewMixin(object):
442
             class BaseViewMixin(object):
438
                 viewset = self
443
                 viewset = self
439
                 view_type = None
444
                 view_type = None
440
-                final_breadcrumb = None
441
                 pk_url_kwarg = viewset.lookup_url_kwarg
445
                 pk_url_kwarg = viewset.lookup_url_kwarg
442
 
446
 
443
                 def get_queryset(self):
447
                 def get_queryset(self):
444
                     return self.viewset.get_queryset(self)
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
                 def get_cached_object(self, viewset=None):
453
                 def get_cached_object(self, viewset=None):
450
                     return self.cached_objects[self.viewset if viewset is None else viewset]
454
                     return self.cached_objects[self.viewset if viewset is None else viewset]
473
                     viewset = self.viewset
477
                     viewset = self.viewset
474
 
478
 
475
                     breadcrumbs = viewset.get_breadcrumbs(self.view_type, self, self.request.GET)
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
                         breadcrumbs.append(
482
                         breadcrumbs.append(
478
                             {
483
                             {
479
-                                'text': self.final_breadcrumb,
484
+                                'text': final_breadcrumb,
480
                                 # Includes the query string.
485
                                 # Includes the query string.
481
                                 'url': self.request.get_full_path()
486
                                 'url': self.request.get_full_path()
482
                             }
487
                             }
489
                     context['is_view_enabled'] = viewset.is_view_enabled
494
                     context['is_view_enabled'] = viewset.is_view_enabled
490
                     context['actions'] = []
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
                         context['actions'].append(
505
                         context['actions'].append(
494
                             [
506
                             [
495
                                 {
507
                                 {
496
                                     'type': 'button',
508
                                     'type': 'button',
497
                                     'text': viewset.get_back_button_text(),
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
                                     'button_class': viewset.get_back_button_class()
511
                                     'button_class': viewset.get_back_button_class()
500
                                 }
512
                                 }
501
                             ]
513
                             ]
640
                             {
652
                             {
641
                                 'type': 'button',
653
                                 'type': 'button',
642
                                 'id': 'id_edit_button',
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
                                 'text': edit_button_text,
656
                                 'text': edit_button_text,
645
                                 'button_class': viewset.get_edit_button_class()
657
                                 'button_class': viewset.get_edit_button_class()
646
                             }
658
                             }
653
                             {
665
                             {
654
                                 'type': 'button',
666
                                 'type': 'button',
655
                                 'id': 'id_delete_button',
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
                                 'text': delete_button_text,
669
                                 'text': delete_button_text,
658
                                 'button_class': viewset.get_delete_button_class()
670
                                 'button_class': viewset.get_delete_button_class()
659
                             }
671
                             }
660
                         )
672
                         )
661
 
673
 
662
                 if action_group:
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
                 context['nested_list_views'] = []
679
                 context['nested_list_views'] = []
666
                 if self.viewset.nested_list_views:
680
                 if self.viewset.nested_list_views:
717
                             [
731
                             [
718
                                 {
732
                                 {
719
                                     'type': 'button',
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
                                     'text': delete_button_text,
735
                                     'text': delete_button_text,
722
                                     'button_class': viewset.get_delete_button_class()
736
                                     'button_class': viewset.get_delete_button_class()
723
                                 }
737
                                 }
775
 
789
 
776
         self.views[DELETE_VIEW_TYPE] = self.get_decorated_view(DeleteView)
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
     @classmethod
815
     @classmethod
779
     def urls(cls):
816
     def urls(cls):
780
         viewset = cls()
817
         viewset = cls()
794
         append_pattern(lookup_regex + 'update/$', UPDATE_VIEW_TYPE)
831
         append_pattern(lookup_regex + 'update/$', UPDATE_VIEW_TYPE)
795
         append_pattern(lookup_regex + 'delete/$', DELETE_VIEW_TYPE)
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
         if not patterns:
838
         if not patterns:
798
             # If a viewset functions only as a nested list view then it will
839
             # If a viewset functions only as a nested list view then it will
799
             # not define any views of its own. We need at least one view URL
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
 from django.core.management.base import BaseCommand, CommandError
1
 from django.core.management.base import BaseCommand, CommandError
2
+from django.db import transaction
2
 import xlrd
3
 import xlrd
3
 from ... import models
4
 from ... import models
4
 from pick import pick
5
 from pick import pick
57
     if index == 0:
58
     if index == 0:
58
         return
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
 class Command(BaseCommand):
70
 class Command(BaseCommand):
69
     help = 'Imports feed items from an Excel spreedsheet.'
71
     help = 'Imports feed items from an Excel spreedsheet.'

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

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
     name = models.CharField(max_length=50, unique=True)
34
     name = models.CharField(max_length=50, unique=True)
35
     description = models.CharField(max_length=255, blank=True)
35
     description = models.CharField(max_length=255, blank=True)
36
     days_required = models.PositiveIntegerField(default=366)
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
     class Meta:
55
     class Meta:
39
         verbose_name = 'Category'
56
         verbose_name = 'Category'

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

10
   <div class="col-xs-10">{{ object.description }}</div>
10
   <div class="col-xs-10">{{ object.description }}</div>
11
 </div>
11
 </div>
12
 <div class="row">
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
   <div class="col-xs-2"><label>Days Required</label></div>
17
   <div class="col-xs-2"><label>Days Required</label></div>
14
   <div class="col-xs-10">{{ object.days_required }}</div>
18
   <div class="col-xs-10">{{ object.days_required }}</div>
15
 </div>
19
 </div>

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

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
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
 from __future__ import unicode_literals
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
 from django.shortcuts import reverse
6
 from django.shortcuts import reverse
7
 from django.utils.text import mark_safe
7
 from django.utils.text import mark_safe
8
 import django_filters
8
 import django_filters
9
-from . import models
9
+from . import models, views
10
 
10
 
11
 class FeedCategoryFilterSet(django_filters.FilterSet):
11
 class FeedCategoryFilterSet(django_filters.FilterSet):
12
     search_expr = django_filters.CharFilter(label='Search', method='filter_search_expr')
12
     search_expr = django_filters.CharFilter(label='Search', method='filter_search_expr')
24
     delete = CrudLinkColumn('delete', text='', verbose_name='', orderable=False, attrs={'a': {'class': 'btn btn-danger btn-xs glyphicon glyphicon-remove', 'role': 'button'}})
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
     class Meta(BaseTable.Meta):
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
         order_by = ('name',)
28
         order_by = ('name',)
29
 
29
 
30
     def render_rss_url(self, value, bound_column):
30
     def render_rss_url(self, value, bound_column):
36
 
36
 
37
     class Meta:
37
     class Meta:
38
         model = models.FeedCategory
38
         model = models.FeedCategory
39
-        fields = ('name', 'description', 'days_required')
39
+        fields = ('name', 'description', 'starting_month', 'days_required')
40
 
40
 
41
 class FeedCategoryViewset(BaseViewset):
41
 class FeedCategoryViewset(BaseViewset):
42
     lookup_url_kwarg = 'feed_category_id'
42
     lookup_url_kwarg = 'feed_category_id'
49
     detail_view_title = 'Category'
49
     detail_view_title = 'Category'
50
     detail_view_template_name = 'feed_content/feed_category_detail_view.html'
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
     def get_queryset(self, view):
60
     def get_queryset(self, view):
53
         return models.FeedCategory.objects.all()
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
 class FeedItemFilterSet(django_filters.FilterSet):
77
 class FeedItemFilterSet(django_filters.FilterSet):
57
     day_number = django_filters.NumberFilter(label='Day')
78
     day_number = django_filters.NumberFilter(label='Day')

+ 4 - 2
rss/feeds.py

21
 
21
 
22
     def items(self, obj):
22
     def items(self, obj):
23
         now = datetime.now()
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
         try:
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
         except FeedItem.DoesNotExist:
30
         except FeedItem.DoesNotExist:
29
             feed_item = None
31
             feed_item = None
30
 
32
 

+ 5 - 0
sms_feed/settings.py

11
 """
11
 """
12
 
12
 
13
 import os
13
 import os
14
+from django.contrib.messages import constants as messages
14
 
15
 
15
 
16
 
16
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
17
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
158
     }
159
     }
159
 }
160
 }
160
 
161
 
162
+MESSAGE_TAGS = {
163
+    messages.ERROR: 'danger'
164
+}
165
+
161
 from settings_local import *
166
 from settings_local import *