Andrew Klopper преди 8 години
ревизия
c277d1bf43
променени са 78 файла, в които са добавени 2740 реда и са изтрити 0 реда
  1. 5 0
      .gitignore
  2. 0 0
      accounts/__init__.py
  3. 8 0
      accounts/apps.py
  4. 4 0
      accounts/decorators.py
  5. 5 0
      accounts/forms.py
  6. 46 0
      accounts/migrations/0001_initial.py
  7. 0 0
      accounts/migrations/__init__.py
  8. 7 0
      accounts/models.py
  9. 36 0
      accounts/templates/accounts/user_detail_view.html
  10. 10 0
      accounts/templates/registration/base.html
  11. 6 0
      accounts/templates/registration/logged_out.html
  12. 26 0
      accounts/templates/registration/login.html
  13. 9 0
      accounts/templates/registration/password_change_done.html
  14. 18 0
      accounts/templates/registration/password_change_form.html
  15. 6 0
      accounts/templates/registration/password_reset_complete.html
  16. 19 0
      accounts/templates/registration/password_reset_confirm.html
  17. 7 0
      accounts/templates/registration/password_reset_done.html
  18. 12 0
      accounts/templates/registration/password_reset_email.html
  19. 14 0
      accounts/templates/registration/password_reset_form.html
  20. 1 0
      accounts/templates/registration/password_reset_subject.txt
  21. 15 0
      accounts/urls.py
  22. 13 0
      accounts/views.py
  23. 90 0
      accounts/viewsets.py
  24. 0 0
      base/__init__.py
  25. 8 0
      base/apps.py
  26. 0 0
      base/static/css/main.css
  27. 0 0
      base/static/js/main.js
  28. 5 0
      base/static/js/vendor/jquery-1.12.4.min.js
  29. 7 0
      base/templates/base/home.html
  30. 39 0
      base/templates/layouts/base.html
  31. 26 0
      base/templates/layouts/crud_shim.html
  32. 58 0
      base/templates/layouts/navbar.html
  33. 0 0
      base/templatetags/__init__.py
  34. 7 0
      base/templatetags/strutils.py
  35. 6 0
      base/urls.py
  36. 6 0
      base/views.py
  37. 11 0
      base/viewsets.py
  38. 0 0
      crud/__init__.py
  39. 8 0
      crud/apps.py
  40. 161 0
      crud/tables2_columns.py
  41. 25 0
      crud/templates/crud/actions.html
  42. 48 0
      crud/templates/crud/base.html
  43. 15 0
      crud/templates/crud/create_view.html
  44. 15 0
      crud/templates/crud/delete_view.html
  45. 9 0
      crud/templates/crud/filter.html
  46. 56 0
      crud/templates/crud/tables2_detail_view.html
  47. 18 0
      crud/templates/crud/tables2_list_view.html
  48. 47 0
      crud/templates/crud/tables2_table.html
  49. 15 0
      crud/templates/crud/update_view.html
  50. 954 0
      crud/viewsets.py
  51. 0 0
      feed_content/__init__.py
  52. 8 0
      feed_content/apps.py
  53. 0 0
      feed_content/management/__init__.py
  54. 97 0
      feed_content/management/commands/import_from_excel.py
  55. 46 0
      feed_content/migrations/0001_initial.py
  56. 20 0
      feed_content/migrations/0002_feedcategory_days_required.py
  57. 0 0
      feed_content/migrations/__init__.py
  58. 98 0
      feed_content/models.py
  59. 20 0
      feed_content/templates/feed_content/feed_category_detail_view.html
  60. 16 0
      feed_content/templates/feed_content/feed_item_detail_view.html
  61. 7 0
      feed_content/urls.py
  62. 6 0
      feed_content/views.py
  63. 118 0
      feed_content/viewsets.py
  64. 22 0
      manage.py
  65. 8 0
      requirements.in
  66. 18 0
      requirements.txt
  67. 0 0
      rss/__init__.py
  68. 64 0
      rss/feeds.py
  69. 34 0
      rss/migrations/0001_initial.py
  70. 0 0
      rss/migrations/__init__.py
  71. 21 0
      rss/models.py
  72. 7 0
      rss/urls.py
  73. 32 0
      rss/viewsets.py
  74. 0 0
      sms_feed/__init__.py
  75. 161 0
      sms_feed/settings.py
  76. 8 0
      sms_feed/url_patterns.py
  77. 12 0
      sms_feed/urls.py
  78. 16 0
      sms_feed/wsgi.py

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
1
+*.pyc
2
+settings_local.py
3
+/.idea/
4
+/db.sqlite3
5
+/static/

+ 0 - 0
accounts/__init__.py


+ 8 - 0
accounts/apps.py

@@ -0,0 +1,8 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.apps import AppConfig
5
+
6
+
7
+class AccountsConfig(AppConfig):
8
+    name = 'accounts'

+ 4 - 0
accounts/decorators.py

@@ -0,0 +1,4 @@
1
+from django.contrib.auth.decorators import user_passes_test
2
+from django.conf import settings
3
+
4
+superuser_required = user_passes_test(lambda u: u.is_superuser, login_url=settings.LOGIN_URL)

+ 5 - 0
accounts/forms.py

@@ -0,0 +1,5 @@
1
+from django import forms
2
+from django.contrib.auth import forms as auth_forms
3
+
4
+class RememberMeAuthenticationForm(auth_forms.AuthenticationForm):
5
+    remember_me = forms.BooleanField(required=False, label='Keep me logged in')

+ 46 - 0
accounts/migrations/0001_initial.py

@@ -0,0 +1,46 @@
1
+# -*- coding: utf-8 -*-
2
+# Generated by Django 1.11.1 on 2017-05-23 10:55
3
+from __future__ import unicode_literals
4
+
5
+import django.contrib.auth.models
6
+import django.contrib.auth.validators
7
+from django.db import migrations, models
8
+import django.utils.timezone
9
+
10
+
11
+class Migration(migrations.Migration):
12
+
13
+    initial = True
14
+
15
+    dependencies = [
16
+        ('auth', '0008_alter_user_username_max_length'),
17
+    ]
18
+
19
+    operations = [
20
+        migrations.CreateModel(
21
+            name='User',
22
+            fields=[
23
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
+                ('password', models.CharField(max_length=128, verbose_name='password')),
25
+                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
26
+                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
27
+                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.ASCIIUsernameValidator()], verbose_name='username')),
28
+                ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
29
+                ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')),
30
+                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
31
+                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
32
+                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
33
+                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
34
+                ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
35
+                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
36
+            ],
37
+            options={
38
+                'abstract': False,
39
+                'verbose_name': 'user',
40
+                'verbose_name_plural': 'users',
41
+            },
42
+            managers=[
43
+                ('objects', django.contrib.auth.models.UserManager()),
44
+            ],
45
+        ),
46
+    ]

+ 0 - 0
accounts/migrations/__init__.py


+ 7 - 0
accounts/models.py

@@ -0,0 +1,7 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.contrib.auth.models import AbstractUser
5
+
6
+class User(AbstractUser):
7
+    pass

+ 36 - 0
accounts/templates/accounts/user_detail_view.html

@@ -0,0 +1,36 @@
1
+{% extends "crud/tables2_detail_view.html" %}
2
+
3
+{% block detail_view_content %}
4
+<div class="row">
5
+  <div class="col-xs-2"><label>Username</label></div>
6
+  <div class="col-xs-10">{{ object.username }}</div>
7
+</div>
8
+<div class="row">
9
+  <div class="col-xs-2"><label>First Name</label></div>
10
+  <div class="col-xs-10">{{ object.first_name }}</div>
11
+</div>
12
+<div class="row">
13
+  <div class="col-xs-2"><label>Last Name</label></div>
14
+  <div class="col-xs-10">{{ object.last_name }}</div>
15
+</div>
16
+<div class="row">
17
+  <div class="col-xs-2"><label>Email</label></div>
18
+  <div class="col-xs-10">{{ object.email }}</div>
19
+</div>
20
+<div class="row">
21
+  <div class="col-xs-2"><label>Superuser</label></div>
22
+  <div class="col-xs-10"><span class="glyphicon glyphicon-{{ object.is_superuser|yesno:"ok,remove" }}"></span></div>
23
+</div>
24
+<div class="row">
25
+  <div class="col-xs-2"><label>Active</label></div>
26
+  <div class="col-xs-10"><span class="glyphicon glyphicon-{{ object.is_active|yesno:"ok,remove" }}"></span></div>
27
+</div>
28
+<div class="row">
29
+  <div class="col-xs-2"><label>Date Joined</label></div>
30
+  <div class="col-xs-10">{{ object.date_joined|date }}, {{ object.date_joined|time }}</div>
31
+</div>
32
+<div class="row">
33
+  <div class="col-xs-2"><label>Last Login</label></div>
34
+  <div class="col-xs-10">{{ object.last_login|date }}, {{ object.last_login|time }}</div>
35
+</div>
36
+{% endblock %}

+ 10 - 0
accounts/templates/registration/base.html

@@ -0,0 +1,10 @@
1
+{% extends "layouts/base.html" %}
2
+{% block body_content %}
3
+<div class="container">
4
+  <div class="row">
5
+    <div class="col-md-6 col-md-offset-3">
6
+      {% block registration_content %}{% endblock %}
7
+    </div>
8
+  </div>
9
+</div>
10
+{% endblock %}

+ 6 - 0
accounts/templates/registration/logged_out.html

@@ -0,0 +1,6 @@
1
+{% extends "registration/base.html" %}
2
+{% block registration_content %}
3
+  <h2>Logged out</h2>
4
+  <p>You have successfully logged out.</p>
5
+  <p><a href="{% url 'home' %}" class="btn btn-primary" role="button">Home</a></p>
6
+{% endblock %}

+ 26 - 0
accounts/templates/registration/login.html

@@ -0,0 +1,26 @@
1
+{% extends "registration/base.html" %}
2
+{% load bootstrap3 %}
3
+{% block registration_content %}
4
+  <h2>Login</h2>
5
+
6
+  {% if next %}
7
+    {% if user.is_authenticated %}
8
+      <div class="alert alert-danger alert-dismissable alert-link">
9
+        <button class="close" type="button" data-dismiss="alert" aria-hidden="true">&#215;</button>
10
+        Your account doesn't have access to this page. To proceed, please login with an account that has access.
11
+      </div>
12
+    {% else %}
13
+      <p>Please login to view this page.</p>
14
+    {% endif %}
15
+  {% endif %}
16
+
17
+  <form class="form" method="post" action="{% url 'login' %}">
18
+    {% csrf_token %}
19
+    <input type="hidden" name="next" value="{{ next }}" />
20
+    {% bootstrap_form form %}
21
+    {% buttons %}
22
+      <button type="submit" class="btn btn-primary">Login</button>
23
+      <a class="btn btn-default" href="{% url 'password_reset' %}">Forgot password</a>
24
+    {% endbuttons %}
25
+  </form>
26
+{% endblock %}

+ 9 - 0
accounts/templates/registration/password_change_done.html

@@ -0,0 +1,9 @@
1
+{% extends "layouts/navbar.html" %}
2
+{% block container_content %}
3
+  <div class="row">
4
+    <div class="col-md-6 col-md-offset-3">
5
+      <h2>Password change succeeded</h2>
6
+      <p>You have successfully changed your password.</p>
7
+    </div>
8
+  </div>
9
+{% endblock %}

+ 18 - 0
accounts/templates/registration/password_change_form.html

@@ -0,0 +1,18 @@
1
+{% extends "layouts/navbar.html" %}
2
+{% load bootstrap3 %}
3
+{% block container_content %}
4
+  <div class="row">
5
+    <div class="col-md-6 col-md-offset-3">
6
+      <h2>Change password</h2>
7
+      <p>Please enter your current password below as a security precaution, followed by your new password twice.</p>
8
+
9
+      <form class="form" method="post">
10
+        {% csrf_token %}
11
+        {% bootstrap_form form %}
12
+        {% buttons %}
13
+          <button type="submit" class="btn btn-primary">Change password</button>
14
+        {% endbuttons %}
15
+      </form>
16
+    </div>
17
+  </div>
18
+{% endblock %}

+ 6 - 0
accounts/templates/registration/password_reset_complete.html

@@ -0,0 +1,6 @@
1
+{% extends "registration/base.html" %}
2
+{% block registration_content %}
3
+  <h2>Password reset succeeded</h2>
4
+  <p>You have successfully changed your password. You may now login.</p>
5
+  <p><a href="{% url 'home' %}" class="btn btn-primary" role="button">Home</a></p>
6
+{% endblock %}

+ 19 - 0
accounts/templates/registration/password_reset_confirm.html

@@ -0,0 +1,19 @@
1
+{% extends "registration/base.html" %}
2
+{% load bootstrap3 %}
3
+{% block registration_content %}
4
+  <h2>Password reset confirmation</h2>
5
+  {% if validlink %}
6
+    <p>Please enter your new password twice below.</p>
7
+    <form class="form" method="post">
8
+      {% csrf_token %}
9
+      {% bootstrap_form form %}
10
+      {% buttons %}
11
+        <button type="submit" class="btn btn-primary">Change password</button>
12
+        <a class="btn btn-default" role="button" href="{% url 'login' %}">Cancel</a>
13
+      {% endbuttons %}
14
+    </form>
15
+  {% else %}
16
+    <p>The password reset link was invalid, possibly because it has already been used. Please request a new password reset.</p>
17
+    <p><a href="{% url 'home' %}" class="btn btn-primary" role="button">Home</a></p>
18
+  {% endif %}
19
+{% endblock %}

+ 7 - 0
accounts/templates/registration/password_reset_done.html

@@ -0,0 +1,7 @@
1
+{% extends "registration/base.html" %}
2
+{% block registration_content %}
3
+  <h2>Password reset</h2>
4
+  <p>We have emailed you instructions for resetting your password, if an account exists with the email address you entered. You should receive the email shortly.</p>
5
+  <p>If you don't receive an email, please make sure you entered the address with which you registered, and check your spam folder.</p>
6
+  <p><a href="{% url 'home' %}" class="btn btn-primary" role="button">Home</a></p>
7
+{% endblock %}

+ 12 - 0
accounts/templates/registration/password_reset_email.html

@@ -0,0 +1,12 @@
1
+{% autoescape off %}
2
+You are receiving this email because you requested a password reset for your account at {{ site_name }}.
3
+
4
+Please go to the following page and choose a new password:
5
+
6
+{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
7
+
8
+Your username, in case you have forgotten it, is: {{ user.get_username }}
9
+
10
+Regards
11
+The {{ site_name }} Team
12
+{% endautoescape %}

+ 14 - 0
accounts/templates/registration/password_reset_form.html

@@ -0,0 +1,14 @@
1
+{% extends "registration/base.html" %}
2
+{% load bootstrap3 %}
3
+{% block registration_content %}
4
+  <h2>Forgot password</h2>
5
+  <p>Please enter the email address that you used to register so that we can send you a link to reset your password.</p>
6
+  <form class="form" method="post">
7
+    {% csrf_token %}
8
+    {% bootstrap_form form %}
9
+    {% buttons %}
10
+      <button type="submit" class="btn btn-primary">Submit</button>
11
+      <a class="btn btn-default" role="button" href="{% url 'login' %}">Cancel</a>
12
+    {% endbuttons %}
13
+  </form>
14
+{% endblock %}

+ 1 - 0
accounts/templates/registration/password_reset_subject.txt

@@ -0,0 +1 @@
1
+{{ site_name }} Password Reset

+ 15 - 0
accounts/urls.py

@@ -0,0 +1,15 @@
1
+from django.conf.urls import url, include
2
+from django.contrib.auth import views as auth_views
3
+from . import views, viewsets
4
+
5
+urlpatterns = [
6
+    url(r'^login/$', views.RememberMeLoginView.as_view(), name='login'),
7
+    url(r'^logout/$', auth_views.LogoutView.as_view(), name='logout'),
8
+    url(r'^password_change/$', auth_views.PasswordChangeView.as_view(), name='password_change'),
9
+    url(r'^password_change/done/$', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
10
+    url(r'^password_reset/$', auth_views.PasswordResetView.as_view(), name='password_reset'),
11
+    url(r'^password_reset/done/$', auth_views.PasswordChangeDoneView.as_view(), name='password_reset_done'),
12
+    url(r'^reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', auth_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
13
+    url(r'^reset/done/$', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
14
+    url(r'^users/', include(viewsets.UserViewset.urls(), namespace='user')),
15
+]

+ 13 - 0
accounts/views.py

@@ -0,0 +1,13 @@
1
+from django.contrib.auth import views as auth_views
2
+import forms
3
+
4
+import logging
5
+logger = logging.getLogger('console')
6
+
7
+class RememberMeLoginView(auth_views.LoginView):
8
+    form_class = forms.RememberMeAuthenticationForm
9
+
10
+    def form_valid(self, form):
11
+        if not form.cleaned_data['remember_me']:
12
+            self.request.session.set_expiry(0)
13
+        return super(RememberMeLoginView, self).form_valid(form)

+ 90 - 0
accounts/viewsets.py

@@ -0,0 +1,90 @@
1
+from django import forms
2
+from base.viewsets import BaseTable, BaseViewset
3
+from crud.tables2_columns import CrudLinkColumn
4
+from django.db.models import Q
5
+from django_tables2 import Column, BooleanColumn, DateTimeColumn
6
+from django.contrib.auth import password_validation
7
+from django.contrib.auth.forms import UsernameField
8
+import django_filters
9
+from crud.tables2_columns import DateTimeColumnEx
10
+from . import models, decorators
11
+
12
+class UserFilterSet(django_filters.FilterSet):
13
+    search_expr = django_filters.CharFilter(label='Search', method='filter_search_expr')
14
+
15
+    class Meta:
16
+        fields = ('search_expr',)
17
+        model = models.User
18
+
19
+    def filter_search_expr(self, queryset, name, value):
20
+        return queryset.filter(Q(username__icontains=value) | Q(first_name__icontains=value) | Q(last_name__icontains=value) | Q(email__icontains=value))
21
+
22
+class UserTable(BaseTable):
23
+    is_active = BooleanColumn(verbose_name='Active')
24
+    is_superuser = BooleanColumn(verbose_name='Superuser')
25
+    last_login = DateTimeColumnEx()
26
+    delete = CrudLinkColumn('delete', text='', verbose_name='', orderable=False, attrs={'a': {'class': 'btn btn-danger btn-xs glyphicon glyphicon-remove', 'role': 'button'}})
27
+
28
+    class Meta(BaseTable.Meta):
29
+        fields = ('username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_active', 'last_login')
30
+        order_by = ('username',)
31
+
32
+class UserForm(forms.ModelForm):
33
+    username = UsernameField()
34
+    email = forms.EmailField(required=True)
35
+    is_superuser = forms.BooleanField(label='Superuser')
36
+    password1 = forms.CharField(
37
+        label="Password",
38
+        strip=False,
39
+        required=False,
40
+        widget=forms.PasswordInput,
41
+        help_text=password_validation.password_validators_help_text_html(),
42
+    )
43
+    password2 = forms.CharField(
44
+        label="Confirm password",
45
+        strip=False,
46
+        required=False,
47
+        widget=forms.PasswordInput,
48
+        help_text="Enter the same password as before, for verification.",
49
+    )
50
+
51
+    class Meta:
52
+        fields = ('username', 'first_name', 'last_name', 'email', 'password1', 'password2', 'is_superuser', 'is_active')
53
+        model = models.User
54
+
55
+    def customise_for_view(self, view):
56
+        if view.view_type == 'create':
57
+            self.fields['password1'].required = True
58
+
59
+    def clean_password2(self):
60
+        password1 = self.cleaned_data.get('password1')
61
+        password2 = self.cleaned_data.get('password2')
62
+        if password1 or password2:
63
+            if password1 != password2:
64
+                raise forms.ValidationError('The two passwords did not match.', code='password_mismatch')
65
+            self.instance.username = self.cleaned_data.get('username')
66
+            password_validation.validate_password(password1, self.instance)
67
+        return password2
68
+
69
+    def save(self, commit=True):
70
+        user = super(UserForm, self).save(commit=False)
71
+        password1 = self.cleaned_data['password1']
72
+        if password1:
73
+            user.set_password(password1)
74
+        if commit:
75
+            user.save()
76
+        return user
77
+
78
+class UserViewset(BaseViewset):
79
+    view_decorator = staticmethod(decorators.superuser_required)
80
+
81
+    filter_class = UserFilterSet
82
+    form_class = UserForm
83
+    tables2_class = UserTable
84
+    tables2_clickable_rows = True
85
+
86
+    detail_view_title = 'User'
87
+    detail_view_template_name = 'accounts/user_detail_view.html'
88
+
89
+    def get_queryset(self, view):
90
+        return models.User.objects.all()

+ 0 - 0
base/__init__.py


+ 8 - 0
base/apps.py

@@ -0,0 +1,8 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.apps import AppConfig
5
+
6
+
7
+class BaseConfig(AppConfig):
8
+    name = 'base'

+ 0 - 0
base/static/css/main.css


+ 0 - 0
base/static/js/main.js


Файловите разлики са ограничени, защото са твърде много
+ 5 - 0
base/static/js/vendor/jquery-1.12.4.min.js


+ 7 - 0
base/templates/base/home.html

@@ -0,0 +1,7 @@
1
+{% extends "layouts/navbar.html" %}
2
+{% block container_content %}
3
+<div class="jumbotron">
4
+  <h1>SMS Feed Manager</h1>
5
+  <p>Please use the menu above to manage the contents of the SMS feeds.</p>
6
+</div>
7
+{% endblock %}

+ 39 - 0
base/templates/layouts/base.html

@@ -0,0 +1,39 @@
1
+{% load staticfiles %}
2
+<html class="no-js" lang="en">
3
+<head>
4
+  <meta charset="utf-8">
5
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
6
+  <meta name="viewport" content="width=device-width, initial-scale=1">
7
+  <title>{% block page_title %}{% endblock %}</title>
8
+
9
+  {% block meta %}{% endblock %}
10
+
11
+  {% block cdn_css %}
12
+  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
13
+  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
14
+  {% endblock cdn_css %}
15
+
16
+  <link rel="stylesheet" href="{% static 'css/main.css' %}">
17
+  {% block css %}{% endblock %}
18
+
19
+  <!--[if lt IE 9]>
20
+    <script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
21
+    <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
22
+  <![endif]-->
23
+</head>
24
+<body class="{% block body_class %}{% endblock %}" {% block body_attributes %}{% endblock %}>
25
+{% block body %}
26
+  {% block body_content %}{% endblock %}
27
+
28
+  {% block cdn_js %}
29
+  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
30
+  <script>window.jQuery || document.write('<script src="{{ STATIC_URL }}js/vendor/jquery-1.12.4.min.js"><\/script>')</script>
31
+  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
32
+  {% endblock cdn_js %}
33
+
34
+  {% block js %}
35
+  <script src="{% static 'js/main.js' %}"></script>
36
+  {% endblock js %}
37
+{% endblock %}
38
+</body>
39
+</html>

+ 26 - 0
base/templates/layouts/crud_shim.html

@@ -0,0 +1,26 @@
1
+{% extends "layouts/navbar.html" %}
2
+
3
+{% block css %}
4
+  {{ block.super }}
5
+  <style>
6
+    .form {
7
+      max-width: 600px;
8
+    }
9
+    .form.form-inline {
10
+      max-width: none;
11
+    }
12
+  </style>
13
+  {% block crud_view_css %}{% endblock %}
14
+{% endblock %}
15
+
16
+{% block js %}
17
+  {{ block.super }}
18
+  {% block crud_view_js %}{% endblock %}
19
+{% endblock %}
20
+
21
+{% block container_content %}
22
+  <ol class="breadcrumb">
23
+    {% block crud_view_breadcrumbs %}{% endblock %}
24
+  </ol>
25
+  {% block crud_view_content %}{% endblock %}
26
+{% endblock %}

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

@@ -0,0 +1,58 @@
1
+{% extends "layouts/base.html" %}
2
+{% load strutils %}
3
+
4
+{% block css %}
5
+{{ block.super }}
6
+<style>
7
+  body {
8
+    min-height: 100vh;
9
+    padding-top: 70px;
10
+  }
11
+</style>
12
+{% endblock %}
13
+
14
+{% block body_content %}
15
+<nav class="navbar navbar-default navbar-fixed-top">
16
+  <div class="container">
17
+    <div class="navbar-header">
18
+      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
19
+        <span class="sr-only">Toggle navigation</span>
20
+        <span class="icon-bar"></span>
21
+        <span class="icon-bar"></span>
22
+        <span class="icon-bar"></span>
23
+      </button>
24
+      <a class="navbar-brand" href="{% url 'home' %}">SMS Feed Manager</a>
25
+    </div>
26
+    <div id="navbar" class="navbar-collapse collapse">
27
+      <ul class="nav navbar-nav">
28
+        <li{% if request.resolver_match.view_name|startswith:"feed_content:" %} class="active"{% endif %}><a href="{% url 'feed_content:list' %}">Feed Content</a></li>
29
+        <li{% if request.resolver_match.view_name|startswith:"rss:daily_logs:" %} class="active"{% endif %}><a href="{% url 'rss:daily_logs:list' %}">Feed Logs</a></li>
30
+        {% if user.is_superuser %}
31
+        <li{% if request.resolver_match.view_name|startswith:"user:" %} class="active"{% endif %}><a href="{% url 'user:list' %}">Users</a></li>
32
+        {% endif %}
33
+      </ul>
34
+      <ul class="nav navbar-nav navbar-right">
35
+        {% if user.is_authenticated %}
36
+          <li class="dropdown">
37
+            <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-user" aria-hidden="true"></span> {{ user.get_username }} <span class="caret"></span></a>
38
+            <ul class="dropdown-menu">
39
+              <li><a href="{% url 'password_change' %}">Change password</a></li>
40
+              <li role="separator" class="divider"></li>
41
+              <li><a href="{% url 'logout' %}">Logout</a></li>
42
+            </ul>
43
+          </li>
44
+        {% else %}
45
+          {% comment %}
46
+          <li><a href="{% url 'register' %}">Register</a></li>
47
+          {% endcomment %}
48
+          <li><a href="{% url 'login' %}">Login</a></li>
49
+        {% endif %}
50
+      </ul>
51
+    </div>
52
+  </div>
53
+</nav>
54
+
55
+<div class="container">
56
+  {% block container_content %}{% endblock %}
57
+</div>
58
+{% endblock body_content %}

+ 0 - 0
base/templatetags/__init__.py


+ 7 - 0
base/templatetags/strutils.py

@@ -0,0 +1,7 @@
1
+from django import template
2
+
3
+register = template.Library()
4
+
5
+@register.filter
6
+def startswith(field, arg):
7
+    return False if field is None else field.startswith(arg) 

+ 6 - 0
base/urls.py

@@ -0,0 +1,6 @@
1
+from django.conf.urls import url
2
+import views
3
+
4
+urlpatterns = [
5
+    url('^$', views.home, name='home'),
6
+]

+ 6 - 0
base/views.py

@@ -0,0 +1,6 @@
1
+from django.contrib.auth.decorators import login_required
2
+from django.shortcuts import render
3
+
4
+@login_required
5
+def home(request):
6
+    return render(request, 'base/home.html')

+ 11 - 0
base/viewsets.py

@@ -0,0 +1,11 @@
1
+from django_tables2 import Table
2
+from crud.tables2_columns import CrudLinkColumn
3
+from crud.viewsets import CrudViewset, Tables2ListViewMixin
4
+from django.contrib.auth.decorators import login_required
5
+
6
+class BaseTable(Table):
7
+    class Meta:
8
+        per_page = 15
9
+
10
+class BaseViewset(Tables2ListViewMixin, CrudViewset):
11
+    view_decorator = staticmethod(login_required)

+ 0 - 0
crud/__init__.py


+ 8 - 0
crud/apps.py

@@ -0,0 +1,8 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.apps import AppConfig
5
+
6
+
7
+class CrudConfig(AppConfig):
8
+    name = 'crud'

+ 161 - 0
crud/tables2_columns.py

@@ -0,0 +1,161 @@
1
+from django.core.exceptions import ImproperlyConfigured
2
+from django.utils.safestring import mark_safe
3
+from django.utils.formats import date_format, time_format
4
+from django.utils.timezone import localtime
5
+from django_tables2 import Column
6
+from django_tables2.columns import library
7
+from django_tables2.columns.linkcolumn import BaseLinkColumn
8
+from django_tables2.utils import Accessor
9
+from viewsets import CrudViewset
10
+import six
11
+import pytz
12
+import urllib
13
+
14
+#import logging
15
+#logger = logging.getLogger('console')
16
+
17
+def render_image_column(url, width=None, height=None, max_width=None, max_height=None, allow_shrink=True, background_position='center', background_size='contain'):
18
+    if max_width is not None:
19
+        if max_height is not None:
20
+            if not allow_shrink or (width is None) or (height is None):
21
+                width = max_width
22
+                height = max_height
23
+            elif (width > max_width) or (height > max_height):
24
+                if width * max_height > height * max_width:
25
+                    height = height * max_width / width
26
+                    width = max_width
27
+                else:
28
+                    width = width * max_height / height
29
+                    height = max_height
30
+        elif (width is not None) and ((width > max_width) or not allow_shrink):
31
+            height = height * max_width / width
32
+            width = max_width
33
+    elif max_height is not None:
34
+        if (height is not None) and ((height > max_height) or not allow_shrink):
35
+            width = width * max_height / height
36
+            height = max_height
37
+
38
+    if (width is None) or (height is None):
39
+        raise ImproperlyConfigured('Insufficient information to determine image width and height')
40
+
41
+    return mark_safe('<div style="width:%dpx;height:%dpx;background:url(%s) no-repeat;background-position: %s; background-size: %s;"></div>' % (width, height, url, urllib.quote(background_position), urllib.quote(background_size)))
42
+
43
+@library.register
44
+class ImageColumn(Column):
45
+    def __init__(self, width_accessor=None, height_accessor=None, max_width=None, max_height=None, allow_shrink=True, background_size='contain', background_position='center', **extra):
46
+        super(ImageColumn, self).__init__(**extra)
47
+
48
+        if not (width_accessor is None or isinstance(width_accessor, six.string_types) or callable(width_accessor)):
49
+            raise TypeError('width_accessor must be a string or callable, not %s' % type(accessor).__name__)
50
+
51
+        if not (height_accessor is None or isinstance(height_accessor, six.string_types) or callable(height_accessor)):
52
+            raise TypeError('height_accessor must be a string or callable, not %s' % type(accessor).__name__)
53
+
54
+        self.width_accessor = Accessor(width_accessor) if width_accessor else None
55
+        self.height_accessor = Accessor(height_accessor) if height_accessor else None
56
+        self.max_width = max_width
57
+        self.max_height = max_height
58
+        self.allow_shrink = allow_shrink
59
+        self.background_size = background_size
60
+        self.background_position = background_position
61
+
62
+    def render(self, record, value, bound_column):
63
+        width = None if self.width_accessor is None else self.width_accessor.resolve(record)
64
+        height = None if self.height_accessor is None else self.height_accessor.resolve(record)
65
+        return render_image_column(value.url, width=width, height=height, max_width=self.max_width, max_height=self.max_height, allow_shrink=self.allow_shrink, background_position=self.background_position, background_size=self.background_size)
66
+
67
+class DateTimeColumnBase(Column):
68
+    def __init__(self, format=None, short=True, timezone=None, use_user_timezone=False, timezone_accessor=None, **extra):
69
+        super(DateTimeColumnBase, self).__init__(**extra)
70
+
71
+        if not (timezone_accessor is None or isinstance(timezone_accessor, six.string_types) or callable(timezone_accessor)):
72
+            raise TypeError('timezone_accessor must be a string or callable, not %s' % type(accessor).__name__)
73
+
74
+        self.timezone = timezone
75
+        self.use_user_timezone = use_user_timezone
76
+        self.timezone_accessor = Accessor(timezone_accessor) if timezone_accessor else None
77
+
78
+        if format is None:
79
+            if short:
80
+                if self.SHORT_FORMAT is None:
81
+                    raise ImproperlyConfigured('%s does not provide a short format' % self.__class__.__name__)
82
+                self.format = self.SHORT_FORMAT
83
+            else:
84
+                self.format = self.LONG_FORMAT
85
+        else:
86
+            self.format = format
87
+
88
+    def convert_value(self, record, value, bound_column):
89
+        if self.timezone is not None:
90
+            tz = self.timezone
91
+        elif self.use_user_timezone:
92
+            tz = pytz.timezone(bound_column._table.view.request.user.time_zone())
93
+        elif self.timezone_accessor is not None:
94
+            tz = pytz.timezone(self.timezone_accessor.resolve(record))
95
+        else:
96
+            # Default to Django's idea of local time
97
+            tz = None
98
+        return localtime(value, tz)
99
+
100
+@library.register
101
+class CrudLinkColumn(BaseLinkColumn):
102
+    def __init__(self, view_type=None, attrs=None, **extra):
103
+        super(CrudLinkColumn, self).__init__(attrs, **extra)
104
+        self.view_type = view_type
105
+
106
+    def render(self, record, value, bound_column):
107
+        table = bound_column._table
108
+        return self.render_link(
109
+            table.viewset.get_view_url(table.viewset.instance_breadcrumb_view_type if self.view_type is None else self.view_type, table.view, table.GET, record),
110
+            record=record,
111
+            value=value
112
+        )
113
+
114
+@library.register
115
+class CrudRelativeLinkColumn(BaseLinkColumn):
116
+    def __init__(self, url_name_suffix, kwargs_callable=None, pk_url_kwarg=None, remove_url_name_components=0, attrs=None, **extra):
117
+        super(CrudRelativeLinkColumn, self).__init__(attrs, **extra)
118
+        self.url_name_suffix = url_name_suffix
119
+        self.kwargs_callable = kwargs_callable
120
+        self.pk_url_kwarg = pk_url_kwarg
121
+        self.remove_url_name_components = remove_url_name_components
122
+
123
+    def render(self, record, value, bound_column):
124
+        table = bound_column._table
125
+        if self.kwargs_callable is not None:
126
+            kwargs = self.kwargs_callable(table.view, record, value)
127
+        elif self.pk_url_kwarg is not None:
128
+            kwargs = {self.pk_url_kwarg: record.pk}
129
+        else:
130
+            kwargs = None
131
+
132
+        return self.render_link(
133
+            table.viewset.get_relative_view_url(self.url_name_suffix, remove_url_name_components=self.remove_url_name_components, kwargs=kwargs, query_params=table.GET),
134
+            record=record,
135
+            value=value
136
+        )
137
+
138
+@library.register
139
+class DateColumnEx(DateTimeColumnBase):
140
+    SHORT_FORMAT = 'SHORT_DATE_FORMAT'
141
+    LONG_FORMAT = 'DATE_FORMAT'
142
+
143
+    def render(self, record, value, bound_column):
144
+        if value is None:
145
+            return bound_column.default
146
+        return date_format(self.convert_value(record, value, bound_column), self.format)
147
+
148
+@library.register
149
+class DateTimeColumnEx(DateColumnEx):
150
+    SHORT_FORMAT = 'SHORT_DATETIME_FORMAT'
151
+    LONG_FORMAT = 'DATETIME_FORMAT'
152
+
153
+@library.register
154
+class TimeColumnEx(DateTimeColumnBase):
155
+    SHORT_FORMAT = None
156
+    LONG_FORMAT = 'TIME_FORMAT'
157
+
158
+    def render(self, record, value, bound_column):
159
+        if value is None:
160
+            return bound_column.default
161
+        return time_format(self.convert_value(record, value, bound_column), self.format)

+ 25 - 0
crud/templates/crud/actions.html

@@ -0,0 +1,25 @@
1
+{% if actions %}
2
+  <div class="btn-toolbar crud-actions" role="toolbar" aria-label="Actions">
3
+    {% for group in actions %}
4
+      <div class="btn-group" role="group" aria-label="Group">
5
+        {% for action in group %}
6
+          {% if action.type == 'menu' %}
7
+            <div class="btn-group" role="group">
8
+              <button type="button" class="btn {{ action.button_class|default:"btn-default" }} dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
9
+                {{ action.text }}
10
+                <span class="caret"></span>
11
+              </button>
12
+              <ul class="dropdown-menu">
13
+                {% for item in action.items %}
14
+                  <li><a href="{{ item.url }}"{% if item.onclick %} onclick="{{ item.onclick }}"{% endif %}>{{ item.text }}</a></li>
15
+                {% endfor %}
16
+              </ul>
17
+            </div>
18
+          {% else %}
19
+            <a href="{{ action.url }}"{% if action.onclick %} onclick="{{ action.onclick }}"{% endif %} class="btn {{ action.button_class|default:"btn-default" }}">{{ action.text }}</a>
20
+          {% endif %}
21
+        {% endfor %}
22
+      </div>
23
+    {% endfor %}
24
+  </div>
25
+{% endif %}

+ 48 - 0
crud/templates/crud/base.html

@@ -0,0 +1,48 @@
1
+{% extends base_template_shim|default:"layouts/crud_shim.html" %}
2
+
3
+{% block crud_view_css %}
4
+  <style>
5
+    .view-title {
6
+      margin-bottom: 5px;
7
+    }
8
+    .crud-actions {
9
+      margin-bottom: 10px;
10
+    }
11
+    .nested-list-views {
12
+      margin-top: 10px;
13
+    }
14
+    .nested-list-views .crud-actions {
15
+      margin-top: 10px;
16
+    }
17
+    .clickable-row {
18
+      cursor: pointer;
19
+    }
20
+  </style>
21
+{% endblock %}
22
+
23
+{% block crud_view_js %}
24
+  <script>
25
+    $(function() {
26
+      $('.clickable-row').click(function() {
27
+        window.location = this.dataset.href;
28
+      });
29
+    });
30
+  </script>
31
+{% endblock %}
32
+
33
+{% block crud_view_breadcrumbs %}
34
+  {% for breadcrumb in breadcrumbs %}
35
+  <li {% if forloop.last %}class="active"{% endif %}>
36
+    {% if not forloop.last and breadcrumb.url %}
37
+      <a href="{{ breadcrumb.url }}">{{ breadcrumb.text }}</a>
38
+    {% else %}
39
+      {{ breadcrumb.text }}
40
+    {% endif %}
41
+  </li>
42
+  {% endfor %}
43
+{% endblock %}
44
+
45
+{% block crud_view_content %}
46
+  {% include "crud/actions.html" %}
47
+  <h1 class="view-title">{{ view_title }}</h1>
48
+{% endblock %}

+ 15 - 0
crud/templates/crud/create_view.html

@@ -0,0 +1,15 @@
1
+{% extends "crud/base.html" %}
2
+{% load bootstrap3 %}
3
+
4
+{% block crud_view_content %}
5
+  {{ block.super }}
6
+  <form action="" method="POST" class="form" enctype="multipart/form-data">
7
+    {% csrf_token %}
8
+    {% bootstrap_form form exclude=form_exclude|join:"," %}
9
+    {% buttons %}
10
+      <button type="submit" class="btn btn-primary">
11
+        Create
12
+      </button>
13
+    {% endbuttons %}
14
+  </form>
15
+{% endblock %}

+ 15 - 0
crud/templates/crud/delete_view.html

@@ -0,0 +1,15 @@
1
+{% extends "crud/base.html" %}
2
+{% load bootstrap3 %}
3
+
4
+{% block crud_view_content %}
5
+  {{ block.super }}
6
+  <form action="" method="POST" class="form">
7
+    {% csrf_token %}
8
+    <p>Are you sure you want to delete this item?</p>
9
+    {% buttons %}
10
+      <button type="submit" class="btn btn-danger">
11
+        Delete
12
+      </button>
13
+    {% endbuttons %}
14
+  </form>
15
+{% endblock %}

+ 9 - 0
crud/templates/crud/filter.html

@@ -0,0 +1,9 @@
1
+{% load bootstrap3 %}
2
+{% if filter is not None %}
3
+  <form action="" method="GET" class="form form-inline">
4
+    {% bootstrap_form filter.form %}
5
+    {% buttons %}
6
+      <button type="submit" class="btn btn-default">Filter</button>
7
+    {% endbuttons %}
8
+  </form>
9
+{% endif %}

+ 56 - 0
crud/templates/crud/tables2_detail_view.html

@@ -0,0 +1,56 @@
1
+{% extends "crud/base.html" %}
2
+{% load static %}
3
+
4
+{% block crud_view_css %}
5
+  {{ block.super }}
6
+  <link rel="stylesheet" href="{% static 'django_tables2/bootstrap.css' %}" />
7
+  <style>
8
+    table.table th a {
9
+      color: inherit;
10
+    }
11
+  </style>
12
+{% endblock %}
13
+
14
+{% block crud_view_js %}
15
+  {{ block.super }}
16
+  <script>
17
+    if (history.pushState) {
18
+      $(document).ready(function() {
19
+        if (location.hash) {
20
+          $('a[href="' + location.hash + '"]').tab('show');
21
+        }
22
+        $(document.body).on('click', 'a[data-toggle="tab"]', function(event) {
23
+          history.pushState(null, null, this.getAttribute('href'));
24
+        });
25
+      });
26
+      $(window).on('popstate', function() {
27
+        var anchor = location.hash || $('a[data-toggle="tab"]').first().attr('href');
28
+        $('a[href="' + anchor + '"]').tab('show');
29
+      });
30
+    }
31
+  </script>
32
+{% endblock %}
33
+
34
+{% block crud_view_content %}
35
+  {{ block.super }}
36
+  {% block detail_view_content %}
37
+  {% endblock %}
38
+  {% if nested_list_views %}
39
+    <div class="nested-list-views">
40
+      <ul class="nav nav-tabs" role="tablist">
41
+        {% for nested_list_view in nested_list_views %}
42
+          <li role="presentation"{% if nested_list_view.active %} class="active"{% endif %}><a href="#{{ nested_list_view.id }}" aria-controls="{{ nested_list_view.id }}" data-toggle="tab">{{ nested_list_view.caption }}</a></li>
43
+        {% endfor %}
44
+      </ul>
45
+      <div class="tab-content">
46
+        {% for nested_list_view in nested_list_views %}
47
+          <div role="tabpanel" class="tab-pane{% if nested_list_view.active %} active{% endif %}" id="{{ nested_list_view.id }}">
48
+            {% include "crud/actions.html" with actions=nested_list_view.actions %}
49
+            {% include "crud/filter.html" with filter=nested_list_view.filter %}
50
+            {% include "crud/tables2_table.html" with table=nested_list_view.table paginator_query_string=nested_list_view.paginator_query_string %}
51
+          </div>
52
+        {% endfor %}
53
+      </div>
54
+    </div>
55
+  {% endif %}
56
+{% endblock %}

+ 18 - 0
crud/templates/crud/tables2_list_view.html

@@ -0,0 +1,18 @@
1
+{% extends "crud/base.html" %}
2
+{% load static %}
3
+
4
+{% block crud_view_css %}
5
+  {{ block.super }}
6
+  <link rel="stylesheet" href="{% static 'django_tables2/bootstrap.css' %}" />
7
+  <style>
8
+    table.table th a {
9
+      color: inherit;
10
+    }
11
+  </style>
12
+{% endblock %}
13
+
14
+{% block crud_view_content %}
15
+  {{ block.super }}
16
+  {% include "crud/filter.html" %}
17
+  {% include "crud/tables2_table.html" %}
18
+{% endblock %}

+ 47 - 0
crud/templates/crud/tables2_table.html

@@ -0,0 +1,47 @@
1
+{% load django_tables2 %}
2
+{% load bootstrap3 %}
3
+{% load i18n %}
4
+
5
+<div class="table-container table-responsive">
6
+  <table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
7
+      {% if table.show_header %}
8
+      <thead>
9
+          <tr>
10
+          {% for column in table.columns %}
11
+              {% if column.orderable %}
12
+              <th {{ column.attrs.th.as_html }}><a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a></th>
13
+              {% else %}
14
+              <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
15
+              {% endif %}
16
+          {% endfor %}
17
+          </tr>
18
+      </thead>
19
+      {% endif %}
20
+      <tbody>
21
+          {% for row in table.page.object_list|default:table.rows %} {# support pagination #}
22
+          <tr {{ row.attrs.as_html }}>
23
+              {% for column, cell in row.items %}
24
+                  <td {{ column.attrs.td.as_html }}>{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}</td>
25
+              {% endfor %}
26
+          </tr>
27
+          {% empty %}
28
+          {% if table.empty_text %}
29
+          <tr><td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td></tr>
30
+          {% endif %}
31
+          {% endfor %}
32
+      </tbody>
33
+      {% if table.has_footer %}
34
+      <tfoot>
35
+          <tr>
36
+          {% for column in table.columns %}
37
+              <td>{{ column.footer }}</td>
38
+          {% endfor %}
39
+          </tr>
40
+      </tfoot>
41
+      {% endif %}
42
+  </table>
43
+
44
+  {% if table.page and table.paginator.num_pages > 1 %}
45
+  {% bootstrap_pagination table.page parameter_name=table.page_field extra=paginator_query_string %}
46
+  {% endif %}
47
+</div>

+ 15 - 0
crud/templates/crud/update_view.html

@@ -0,0 +1,15 @@
1
+{% extends "crud/base.html" %}
2
+{% load bootstrap3 %}
3
+
4
+{% block crud_view_content %}
5
+  {{ block.super }}
6
+  <form action="" method="POST" class="form" enctype="multipart/form-data">
7
+    {% csrf_token %}
8
+    {% bootstrap_form form exclude=form_exclude|join:"," %}
9
+    {% buttons %}
10
+      <button type="submit" class="btn btn-primary">
11
+        Update
12
+      </button>
13
+    {% endbuttons %}
14
+  </form>
15
+{% endblock %}

+ 954 - 0
crud/viewsets.py

@@ -0,0 +1,954 @@
1
+from django.conf.urls import url
2
+from django.contrib.auth.decorators import login_required
3
+from django.core.exceptions import ImproperlyConfigured, ValidationError
4
+from django.db.models.query import QuerySet
5
+from django.http import Http404
6
+from django.urls import reverse
7
+from django.shortcuts import get_object_or_404
8
+from django.views import generic
9
+import django_tables2
10
+
11
+import copy
12
+import threading
13
+
14
+#import logging
15
+#logger = logging.getLogger("console")
16
+
17
+LIST_VIEW_TYPE = 'list'
18
+CREATE_VIEW_TYPE = 'create'
19
+DETAIL_VIEW_TYPE = 'detail'
20
+UPDATE_VIEW_TYPE = 'update'
21
+DELETE_VIEW_TYPE = 'delete'
22
+
23
+# Used in the case where a viewset functions only as a nested list view, and
24
+# therefore doesn't require any URLs of its own.
25
+NOT_FOUND_VIEW_TYPE = 'not_found'
26
+
27
+INSTANCE_VIEW_TYPES = [DETAIL_VIEW_TYPE, UPDATE_VIEW_TYPE, DELETE_VIEW_TYPE]
28
+
29
+class SaveErrorHandlerMixin(object):
30
+    def form_valid(self, form):
31
+        try:
32
+            return super(SaveErrorHandlerMixin, self).form_valid(form)
33
+        except ValidationError as e:
34
+            for field, errors in e:
35
+                form.add_error(field, errors)
36
+            return self.form_invalid(form)
37
+
38
+class CrudViewset(object):
39
+    model = None
40
+    queryset = None
41
+
42
+    root_breadcrumbs = []
43
+
44
+    master_viewset_class = None
45
+    nested_list_view_index = None
46
+
47
+    # Setting this to any of the instance view types will cause the master
48
+    # viewset to use its instance_breadcrumb_view_type setting, so the only
49
+    # other value that makes sense is LIST_VIEW_TYPE.
50
+    master_viewset_breadcrumb_view_type = DETAIL_VIEW_TYPE
51
+
52
+    # instance_breadcrumb_text.format(instance)
53
+    instance_breadcrumb_text = u"{0}"
54
+    instance_breadcrumb_view_type = DETAIL_VIEW_TYPE
55
+
56
+    lookup_field = 'pk'
57
+    lookup_url_kwarg = 'pk'
58
+    lookup_url_kwarg_pattern = r'\d+'
59
+
60
+    lookup_regex = None
61
+
62
+    view_url_kwarg_names = []
63
+
64
+    active_nested_list_view_query_param = 't'
65
+
66
+    view_decorator = staticmethod(login_required)
67
+
68
+    exclude_views = []
69
+
70
+    create_button_text = 'Create'
71
+    create_button_class = 'btn-primary'
72
+
73
+    edit_button_text = 'Edit'
74
+    edit_button_class = 'btn-primary'
75
+
76
+    delete_button_text = 'Delete'
77
+    delete_button_class = 'btn-danger'
78
+
79
+    back_button_text = 'Back'
80
+    back_button_class = 'btn-default'
81
+
82
+    base_template_shim = None
83
+
84
+    list_view_template_name = None
85
+    create_view_template_name = 'crud/create_view.html'
86
+    detail_view_template_name = 'crud/detail_view.html'
87
+    update_view_template_name = 'crud/update_view.html'
88
+    delete_view_template_name = 'crud/delete_view.html'
89
+
90
+    # list_view_title.format(model.verbose_name_plural) if model can be
91
+    # determined. The value is set to None here in case model cannot be
92
+    # determined, in which case a specific value must be provided.
93
+    list_view_title = None
94
+
95
+    # create_view_title.format(model.verbose_name) if model can be determined.
96
+    # The value is set to None here in case model cannot be determined, in
97
+    # which case a specific value must be provided.
98
+    create_view_title = None
99
+
100
+    # *_view_title.format(view.object)
101
+    detail_view_title = u"{0}"
102
+    update_view_title = u"Edit {0}"
103
+    delete_view_title = u"Delete {0}"
104
+
105
+    success_url = None
106
+    create_view_success_url = None
107
+    update_view_success_url = None
108
+    delete_view_success_url = None
109
+
110
+    filter_class = None
111
+
112
+    form_class = None
113
+    create_view_form_class = None
114
+    update_view_form_class = None
115
+
116
+    form_exclude = []
117
+    create_view_form_exclude = None
118
+    update_view_form_exclude = None
119
+
120
+    list_view_base_class = generic.ListView
121
+    create_view_base_class = generic.CreateView
122
+
123
+    def __init__(self):
124
+        self.master_viewset = None
125
+        self.nested_list_views = []
126
+        self.is_list_view_nested = (self.master_viewset_class is not None) and (self.nested_list_view_index is not None)
127
+        self.list_view_query_param_prefix = self.get_list_view_query_param_prefix()
128
+
129
+        # This will be set to an empty string later if no nested list views are
130
+        # linked to this view. We need the value in the detail view before then,
131
+        # however.
132
+        self.nested_list_view_query_param_prefix = self.get_nested_list_view_query_param_prefix()
133
+
134
+        # Remove the trailing '-' as django_filter adds it in again.
135
+        self.filter_query_param_prefix = self.list_view_query_param_prefix[:-1]
136
+
137
+        # Cache the get_filter_class result in an instance attribute, which will
138
+        # hide the class attribute of the same name.
139
+        self.filter_class = self.get_filter_class()
140
+
141
+        self.base_view_mixin = None
142
+        self.views = {}
143
+
144
+        self.exclude_views = set(self.exclude_views)
145
+        if self.is_list_view_nested:
146
+            self.exclude_views.add(LIST_VIEW_TYPE)
147
+
148
+        if self.model is None:
149
+            queryset = self.get_queryset(None)
150
+            if isinstance(queryset, QuerySet):
151
+                self.model = queryset.model
152
+        elif self.queryset is None:
153
+            # It's the user's problem if they define 'model' and override
154
+            # 'get_queryset' at the same time.
155
+            self.queryset = self.model._default_manager.all()
156
+        else:
157
+            raise ImproperlyConfigured("%s contains definitions for both 'model' and 'queryset', but only one or the other is allowed" % self.__class__.__name__)
158
+
159
+        # If model is None then list_view_title and create_view_title will be
160
+        # left with their default values (which are None by default and will
161
+        # cause the corresponding getters to raise an error).
162
+        if self.model is not None:
163
+            if self.list_view_title is None:
164
+                self.list_view_title = u"{0}"
165
+            self.list_view_title = self.list_view_title.format(self.model._meta.verbose_name_plural.title())
166
+
167
+            if self.create_view_title is None:
168
+                self.create_view_title = u"Create {0}"
169
+            self.create_view_title = self.create_view_title.format(self.model._meta.verbose_name.title())
170
+
171
+        self.generate_list_view()
172
+        self.generate_create_view()
173
+        self.generate_detail_view()
174
+        self.generate_update_view()
175
+        self.generate_delete_view()
176
+
177
+        self.is_view_enabled = {view_type: view is not None for view_type, view in self.views.items()}
178
+
179
+        # For convenience.
180
+        if not self.is_view_enabled[DETAIL_VIEW_TYPE] and (self.instance_breadcrumb_view_type == DETAIL_VIEW_TYPE):
181
+            self.instance_breadcrumb_view_type = UPDATE_VIEW_TYPE
182
+
183
+    @staticmethod
184
+    def reverse_with_query_params(viewname, *args, **kwargs):
185
+        query_params = kwargs.pop('query_params', None)
186
+        url = reverse(viewname, *args, **kwargs)
187
+        if query_params:
188
+            url += '?' + query_params.urlencode()
189
+        return url
190
+
191
+    @staticmethod
192
+    def get_parent_url_name(url_name, remove_count=1):
193
+        return ':'.join(url_name.split(':')[:-remove_count])
194
+
195
+    @staticmethod
196
+    def derive_view_url_kwarg_names(master_viewset_class):
197
+        return master_viewset_class.view_url_kwarg_names + [master_viewset_class.lookup_url_kwarg]
198
+
199
+    @classmethod
200
+    def depth(cls):
201
+        return 0 if cls.master_viewset_class is None else cls.master_viewset_class.depth() + 1
202
+
203
+    def get_master_object(self, view, instance):
204
+        raise ImproperlyConfigured("%s requires an implementation of 'get_master_object'" % self.__class__.__name__)
205
+
206
+    def get_list_view_query_param_prefix_core(self, relative_depth):
207
+        # The prefix must include a trailing '-' to match the way django_filter
208
+        # handles prefixes.
209
+        prefix = 'd%d-' % (self.depth() + relative_depth)
210
+        if self.is_list_view_nested and (relative_depth == 0):
211
+            return '%s%d-' % (prefix, self.nested_list_view_index)
212
+        else:
213
+            return prefix
214
+
215
+    def get_list_view_query_param_prefix(self):
216
+        return self.get_list_view_query_param_prefix_core(0)
217
+
218
+    def get_nested_list_view_query_param_prefix(self):
219
+        return self.get_list_view_query_param_prefix_core(1)
220
+
221
+    def remove_list_view_query_params_core(self, GET, prefix):
222
+        if prefix != '':
223
+            filtered = GET.copy()
224
+            for key in filtered.keys():
225
+                if key.startswith(prefix):
226
+                    filtered.pop(key, None)
227
+            return filtered
228
+        return GET
229
+
230
+    def remove_list_view_query_params(self, GET):
231
+        return self.remove_list_view_query_params_core(GET, self.list_view_query_param_prefix)
232
+
233
+    def remove_nested_list_view_query_params(self, GET):
234
+        return self.remove_list_view_query_params_core(GET, self.nested_list_view_query_param_prefix)
235
+
236
+    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}
238
+        if view_type in INSTANCE_VIEW_TYPES:
239
+            if isinstance(instance, dict):
240
+                kwargs[self.lookup_url_kwarg] = instance.get(self.lookup_field)
241
+            else:
242
+                kwargs[self.lookup_url_kwarg] = getattr(instance, self.lookup_field)
243
+        return kwargs
244
+
245
+    def get_view_url(self, view_type, view, GET, instance=None):
246
+        if self.is_list_view_nested and (view_type == LIST_VIEW_TYPE):
247
+            return self.master_viewset.get_view_url(self.master_viewset.instance_breadcrumb_view_type, view, GET, view.cached_objects[self.master_viewset])
248
+        else:
249
+            return self.reverse_with_query_params(self.url_name_prefix + view_type, kwargs=self.get_view_url_kwargs(view_type, view, instance), query_params=self.remove_nested_list_view_query_params(GET) if view_type == LIST_VIEW_TYPE else GET)
250
+
251
+    def get_root_breadcrumbs(self, view):
252
+        return self.root_breadcrumbs
253
+
254
+    def get_list_view_breadcrumb(self, view, GET, instance=None):
255
+        return {
256
+            'text': self.get_list_view_title(view),
257
+            'url': self.get_view_url(LIST_VIEW_TYPE, view, GET)
258
+        }
259
+
260
+    def get_instance_breadcrumb_text(self, view, instance):
261
+        return self.instance_breadcrumb_text.format(instance)
262
+
263
+    def get_breadcrumbs(self, view_type, view, GET):
264
+        breadcrumbs = []
265
+
266
+        if self.master_viewset is None:
267
+            breadcrumbs += self.get_root_breadcrumbs(view)
268
+        else:
269
+            breadcrumbs += self.master_viewset.get_breadcrumbs(self.master_viewset_breadcrumb_view_type, view, GET if self.is_list_view_nested else self.remove_list_view_query_params(GET))
270
+
271
+        instance = view.cached_objects[self]
272
+
273
+        # The list view of nested viewsets is part of the detail view of the
274
+        # master viewset, and the breadcrumb will already have been added above.
275
+        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))
277
+
278
+        if view_type in INSTANCE_VIEW_TYPES:
279
+            breadcrumbs += [{
280
+                'text': self.get_instance_breadcrumb_text(view, instance),
281
+                'url': self.get_view_url(self.instance_breadcrumb_view_type, view, GET, instance)
282
+            }]
283
+
284
+        return breadcrumbs
285
+
286
+    def get_list_view_title(self, view):
287
+        if self.list_view_title is None:
288
+            raise ImproperlyConfigured("%s requires either a definition of 'list_view_title' or an implementation of 'get_list_view_title'" % self.__class__.__name__)
289
+        return self.list_view_title
290
+
291
+    def get_create_view_title(self, view):
292
+        if self.create_view_title is None:
293
+            raise ImproperlyConfigured("%s requires either a definition of 'create_view_title' or an implementation of 'get_create_view_title'" % self.__class__.__name__)
294
+        return self.create_view_title
295
+
296
+    def get_detail_view_title(self, view):
297
+        return self.detail_view_title.format(view.object)
298
+
299
+    def get_update_view_title(self, view):
300
+        return self.update_view_title.format(view.object)
301
+
302
+    def get_delete_view_title(self, view):
303
+        return self.delete_view_title.format(view.object)
304
+
305
+    """
306
+    If get_queryset is overridden to return an iterable rather than a QuerySet
307
+    you must provide an implementation of:
308
+
309
+        get_object(self, view, queryset=None)
310
+
311
+    in order for instance views to work correctly. You can also provide an
312
+    implementation of:
313
+
314
+        delete_object(self, view, request, *args, **kwargs)
315
+    
316
+    to implement custom deletion logic.
317
+    """
318
+    def get_queryset(self, view):
319
+        if self.queryset is None:
320
+            raise ImproperlyConfigured("%s requires either a definition of 'model' or 'queryset', or an implementation of 'get_queryset'" % self.__class__.__name__)
321
+        return self.queryset
322
+
323
+    def get_object(self, view, queryset=None):
324
+        if queryset is None:
325
+            queryset = self.get_queryset(view)
326
+        queryset = queryset.filter(**{self.lookup_field: view.kwargs[self.lookup_url_kwarg]})
327
+        return get_object_or_404(queryset)
328
+
329
+    def get_create_view_object(self, view):
330
+        return None
331
+
332
+    def add_context_data(self, view, context):
333
+        pass
334
+
335
+    def add_list_view_context_data(self, view, GET, context):
336
+        pass
337
+
338
+    def create_view_form_valid(self, view, form):
339
+        pass
340
+
341
+    def update_view_form_valid(self, view, form):
342
+        pass
343
+
344
+    def get_filter_class(self):
345
+        return self.filter_class
346
+
347
+    def get_form_class(self):
348
+        if self.form_class is None:
349
+            raise ImproperlyConfigured("%s requires a definition of either 'form_class' or the appropriate combination of 'create_view_form_class' and 'update_view_form_class'" % self.__class__.__name__)
350
+        return self.form_class
351
+
352
+    def get_create_view_form_class(self):
353
+        if self.create_view_form_class is None:
354
+            return self.get_form_class()
355
+        return self.create_view_form_class
356
+
357
+    def get_update_view_form_class(self):
358
+        if self.update_view_form_class is None:
359
+            return self.get_form_class()
360
+        return self.update_view_form_class
361
+
362
+    def get_form_exclude(self, view):
363
+        return self.form_exclude
364
+
365
+    def get_create_view_form_exclude(self, view):
366
+        if self.create_view_form_exclude is None:
367
+            return self.get_form_exclude(view)
368
+        return self.create_view_form_exclude
369
+
370
+    def get_update_view_form_exclude(self, view):
371
+        if self.update_view_form_exclude is None:
372
+            return self.get_form_exclude(view)
373
+        return self.update_view_form_exclude
374
+
375
+    def get_create_view_form_initial(self, view):
376
+        return {}
377
+
378
+    def get_update_view_form_initial(self, view):
379
+        return {}
380
+
381
+    def get_create_button_text(self):
382
+        return self.create_button_text
383
+
384
+    def get_create_button_class(self):
385
+        return self.create_button_class
386
+
387
+    def get_edit_button_text(self):
388
+        return self.edit_button_text
389
+
390
+    def get_edit_button_class(self):
391
+        return self.edit_button_class
392
+
393
+    def get_delete_button_text(self):
394
+        return self.delete_button_text
395
+
396
+    def get_delete_button_class(self):
397
+        return self.delete_button_class
398
+
399
+    def get_back_button_text(self):
400
+        return self.back_button_text
401
+
402
+    def get_back_button_class(self):
403
+        return self.back_button_class
404
+
405
+    def get_relative_view_url(self, url_name_suffix, remove_url_name_components=0, kwargs=None, query_params=None):
406
+        if remove_url_name_components > 0:
407
+            # url_name_prefix has a trailing colon so we always need to remove
408
+            # an extra component.
409
+            url_name_prefix = self.get_parent_url_name(self.url_name_prefix, remove_url_name_components + 1) + ':'
410
+        else:
411
+            url_name_prefix = self.url_name_prefix
412
+
413
+        return self.reverse_with_query_params(url_name_prefix + url_name_suffix, kwargs=kwargs, query_params=query_params)
414
+
415
+    def get_success_url(self, view):
416
+        if self.success_url is None:
417
+            return self.get_view_url(LIST_VIEW_TYPE, view, view.request.GET)
418
+        return self.success_url
419
+
420
+    def get_create_view_success_url(self, view):
421
+        if self.create_view_success_url is None:
422
+            return self.get_success_url(view)
423
+        return self.create_view_success_url
424
+
425
+    def get_update_view_success_url(self, view):
426
+        if self.update_view_success_url is None:
427
+            return self.get_success_url(view)
428
+        return self.update_view_success_url
429
+
430
+    def get_delete_view_success_url(self, view):
431
+        if self.delete_view_success_url is None:
432
+            return self.get_success_url(view)
433
+        return self.delete_view_success_url
434
+
435
+    def get_base_view_mixin(self):
436
+        if self.base_view_mixin is None:
437
+            class BaseViewMixin(object):
438
+                viewset = self
439
+                view_type = None
440
+                final_breadcrumb = None
441
+                pk_url_kwarg = viewset.lookup_url_kwarg
442
+
443
+                def get_queryset(self):
444
+                    return self.viewset.get_queryset(self)
445
+
446
+                def get_view_title(self):
447
+                    raise NotImplementedError
448
+
449
+                def get_cached_object(self, viewset=None):
450
+                    return self.cached_objects[self.viewset if viewset is None else viewset]
451
+
452
+                def get_object(self, *args, **kwargs):
453
+                    return self.cached_objects[self.viewset]
454
+
455
+                def populate_object_cache(self, instance):
456
+                    # Populate a cache of objects for all viewsets in the
457
+                    # master-detail chain so all lookup logic is in one place
458
+                    # and we don't have to worry about accidentally doing
459
+                    # the same lookup twice in other functions.
460
+                    viewset = self.viewset
461
+                    viewset_object = instance
462
+                    self.cached_objects = {viewset: viewset_object}
463
+                    while viewset.master_viewset is not None:
464
+                        if viewset_object is None:
465
+                            viewset_object = viewset.master_viewset.get_object(self)
466
+                        else:
467
+                            viewset_object = viewset.get_master_object(self, viewset_object)
468
+                        viewset = viewset.master_viewset
469
+                        self.cached_objects[viewset] = viewset_object
470
+
471
+                def get_context_data(self, *args, **kwargs):
472
+                    context = super(BaseViewMixin, self).get_context_data(*args, **kwargs)
473
+                    viewset = self.viewset
474
+
475
+                    breadcrumbs = viewset.get_breadcrumbs(self.view_type, self, self.request.GET)
476
+                    if self.final_breadcrumb is not None:
477
+                        breadcrumbs.append(
478
+                            {
479
+                                'text': self.final_breadcrumb,
480
+                                # Includes the query string.
481
+                                'url': self.request.get_full_path()
482
+                            }
483
+                        )
484
+
485
+                    context['base_template_shim'] = viewset.base_template_shim
486
+                    context['breadcrumbs'] = breadcrumbs
487
+                    context['query_string'] = self.request.GET.urlencode()
488
+                    context['view_title'] = self.get_view_title()
489
+                    context['is_view_enabled'] = viewset.is_view_enabled
490
+                    context['actions'] = []
491
+
492
+                    if (self.view_type != LIST_VIEW_TYPE) and (viewset.is_list_view_nested or viewset.is_view_enabled[LIST_VIEW_TYPE]):
493
+                        context['actions'].append(
494
+                            [
495
+                                {
496
+                                    'type': 'button',
497
+                                    'text': viewset.get_back_button_text(),
498
+                                    'url': viewset.get_view_url(LIST_VIEW_TYPE, self, self.request.GET),
499
+                                    'button_class': viewset.get_back_button_class()
500
+                                }
501
+                            ]
502
+                        )
503
+
504
+                    viewset.add_context_data(self, context)
505
+
506
+                    return context
507
+
508
+            self.base_view_mixin = BaseViewMixin
509
+
510
+        return self.base_view_mixin
511
+
512
+    def get_decorated_view(self, view_class):
513
+        view = view_class.as_view()
514
+        if self.view_decorator is not None:
515
+            view = self.view_decorator(view)
516
+        view.viewset = self
517
+        return view
518
+
519
+    def is_view_generated(self, view_type):
520
+        if view_type in self.views:
521
+            return True
522
+        if view_type in self.exclude_views:
523
+            self.views[view_type] = None
524
+            return True
525
+        return False
526
+
527
+    def generate_list_view(self):
528
+        if self.is_view_generated(LIST_VIEW_TYPE):
529
+            return
530
+
531
+        class ListView(self.get_base_view_mixin(), self.list_view_base_class):
532
+            viewset = self
533
+            view_type = LIST_VIEW_TYPE
534
+            template_name = self.list_view_template_name
535
+            filter = None
536
+
537
+            def dispatch(self, request, *args, **kwargs):
538
+                self.populate_object_cache(None)
539
+                return super(ListView, self).dispatch(request, *args, **kwargs)
540
+
541
+            def get_view_title(self):
542
+                return self.viewset.get_list_view_title(self)
543
+
544
+            def get_context_data(self, *args, **kwargs):
545
+                context = super(ListView, self).get_context_data(*args, **kwargs)
546
+                if self.filter is not None:
547
+                    context['filter'] = self.filter
548
+                self.viewset.add_list_view_context_data(self, self.request.GET, context)
549
+                return context
550
+
551
+        if self.filter_class is not None:
552
+            filter_class = self.filter_class
553
+            prefix = self.filter_query_param_prefix
554
+
555
+            # NOTE: This will only be called if base_list_view_class is a
556
+            # ListView, not a TemplateView.
557
+            def get_queryset(self):
558
+                # Sanity check: verify that get_queryset() is called only once
559
+                # per request.
560
+                if self.filter is not None:
561
+                    raise RuntimeError("ListView.get_queryset() was called more than once")
562
+                self.filter = filter_class(self.request.GET, super(ListView, self).get_queryset(), prefix=prefix)
563
+                return self.filter.qs
564
+
565
+            ListView.get_queryset = get_queryset
566
+
567
+        self.views[LIST_VIEW_TYPE] = self.get_decorated_view(ListView)
568
+
569
+    def generate_create_view(self):
570
+        if self.is_view_generated(CREATE_VIEW_TYPE):
571
+            return
572
+
573
+        class CreateView(SaveErrorHandlerMixin, self.get_base_view_mixin(), self.create_view_base_class):
574
+            viewset = self
575
+            view_type = CREATE_VIEW_TYPE
576
+            form_class = self.get_create_view_form_class()
577
+            template_name = self.create_view_template_name
578
+            final_breadcrumb = 'Create'
579
+
580
+            def dispatch(self, request, *args, **kwargs):
581
+                self.populate_object_cache(self.viewset.get_create_view_object(self))
582
+                return super(CreateView, self).dispatch(request, *args, **kwargs)
583
+
584
+            def get_view_title(self):
585
+                return self.viewset.get_create_view_title(self)
586
+
587
+            def get_context_data(self, *args, **kwargs):
588
+                context = super(CreateView, self).get_context_data(*args, **kwargs)
589
+                context['form_exclude'] = self.viewset.get_create_view_form_exclude(self)
590
+                return context
591
+
592
+            def form_valid(self, form):
593
+                self.viewset.create_view_form_valid(self, form)
594
+                return super(CreateView, self).form_valid(form)
595
+
596
+            def get_initial(self):
597
+                return self.viewset.get_create_view_form_initial(self)
598
+
599
+            def get_form(self, form_class=None):
600
+                form = super(CreateView, self).get_form(form_class)
601
+                form.view = self
602
+                customise = getattr(form, 'customise_for_view', None)
603
+                if customise is not None:
604
+                    customise(self)
605
+                return form
606
+
607
+            def get_success_url(self):
608
+                return self.viewset.get_create_view_success_url(self)
609
+
610
+        self.views[CREATE_VIEW_TYPE] = self.get_decorated_view(CreateView)
611
+
612
+    def generate_detail_view(self):
613
+        if self.is_view_generated(DETAIL_VIEW_TYPE):
614
+            return
615
+
616
+        active_nested_list_view_query_param = self.nested_list_view_query_param_prefix + self.active_nested_list_view_query_param
617
+
618
+        class DetailView(self.get_base_view_mixin(), generic.DetailView):
619
+            viewset = self
620
+            view_type = DETAIL_VIEW_TYPE
621
+            template_name = self.detail_view_template_name
622
+
623
+            def dispatch(self, request, *args, **kwargs):
624
+                self.populate_object_cache(self.viewset.get_object(self))
625
+                return super(DetailView, self).dispatch(request, *args, **kwargs)
626
+
627
+            def get_view_title(self):
628
+                return self.viewset.get_detail_view_title(self)
629
+
630
+            def get_context_data(self, *args, **kwargs):
631
+                context = super(DetailView, self).get_context_data(*args, **kwargs)
632
+
633
+                viewset = self.viewset
634
+
635
+                action_group = []
636
+                if viewset.is_view_enabled[UPDATE_VIEW_TYPE]:
637
+                    edit_button_text = viewset.get_edit_button_text()
638
+                    if edit_button_text is not None:
639
+                        action_group.append(
640
+                            {
641
+                                'type': 'button',
642
+                                'url': viewset.get_view_url(UPDATE_VIEW_TYPE, self, self.request.GET, self.get_cached_object()),
643
+                                'text': edit_button_text,
644
+                                'button_class': viewset.get_edit_button_class()
645
+                            }
646
+                        )
647
+
648
+                if viewset.is_view_enabled[DELETE_VIEW_TYPE]:
649
+                    delete_button_text = viewset.get_delete_button_text()
650
+                    if delete_button_text is not None:
651
+                        action_group.append(
652
+                            {
653
+                                'type': 'button',
654
+                                'url': viewset.get_view_url(DELETE_VIEW_TYPE, self, self.request.GET, self.get_cached_object()),
655
+                                'text': delete_button_text,
656
+                                'button_class': viewset.get_delete_button_class()
657
+                            }
658
+                        )
659
+
660
+                if action_group:
661
+                    context['actions'].append(action_group)
662
+
663
+                context['nested_list_views'] = []
664
+                if self.viewset.nested_list_views:
665
+                    active_index = self.request.GET.get(active_nested_list_view_query_param, '')
666
+                    have_active = False
667
+                    for nested_viewset in self.viewset.nested_list_views:
668
+                        index = str(nested_viewset.nested_list_view_index)
669
+                        active = index == active_index
670
+                        have_active = have_active or active
671
+                        GET = self.request.GET.copy()
672
+                        GET[active_nested_list_view_query_param] = index
673
+                        nested_context = {
674
+                            'active': active,
675
+                            'id': 't' + index,
676
+                            'caption': nested_viewset.get_list_view_title(self),
677
+                            'actions': []
678
+                        }
679
+                        nested_viewset.add_list_view_context_data(self, GET, nested_context)
680
+                        context['nested_list_views'].append(nested_context)
681
+                    if not have_active:
682
+                        context['nested_list_views'][0]['active'] = True
683
+
684
+                return context
685
+
686
+        self.views[DETAIL_VIEW_TYPE] = self.get_decorated_view(DetailView)
687
+
688
+    def generate_update_view(self):
689
+        if self.is_view_generated(UPDATE_VIEW_TYPE):
690
+            return
691
+
692
+        class UpdateView(SaveErrorHandlerMixin, self.get_base_view_mixin(), generic.UpdateView):
693
+            viewset = self
694
+            view_type = UPDATE_VIEW_TYPE
695
+            form_class = self.get_update_view_form_class()
696
+            template_name = self.update_view_template_name
697
+            final_breadcrumb = None if self.instance_breadcrumb_view_type == UPDATE_VIEW_TYPE else 'Update'
698
+
699
+            def dispatch(self, request, *args, **kwargs):
700
+                self.populate_object_cache(self.viewset.get_object(self))
701
+                return super(UpdateView, self).dispatch(request, *args, **kwargs)
702
+
703
+            def get_view_title(self):
704
+                return self.viewset.get_update_view_title(self)
705
+
706
+            def get_context_data(self, *args, **kwargs):
707
+                context = super(UpdateView, self).get_context_data(*args, **kwargs)
708
+
709
+                viewset = self.viewset
710
+
711
+                if viewset.is_view_enabled[DELETE_VIEW_TYPE]:
712
+                    delete_button_text = viewset.get_delete_button_text()
713
+                    if delete_button_text is not None:
714
+                        context['actions'].append(
715
+                            [
716
+                                {
717
+                                    'type': 'button',
718
+                                    'url': viewset.get_view_url(DELETE_VIEW_TYPE, self, self.request.GET, self.get_cached_object()),
719
+                                    'text': delete_button_text,
720
+                                    'button_class': viewset.get_delete_button_class()
721
+                                }
722
+                            ]
723
+                        )
724
+
725
+                context['form_exclude'] = self.viewset.get_update_view_form_exclude(self)
726
+                return context
727
+
728
+            def form_valid(self, form):
729
+                self.viewset.update_view_form_valid(self, form)
730
+                return super(UpdateView, self).form_valid(form)
731
+
732
+            def get_initial(self):
733
+                return self.viewset.get_update_view_form_initial(self)
734
+
735
+            def get_form(self, form_class=None):
736
+                form = super(UpdateView, self).get_form(form_class)
737
+                form.view = self
738
+                customise = getattr(form, 'customise_for_view', None)
739
+                if customise is not None:
740
+                    customise(self)
741
+                return form
742
+
743
+            def get_success_url(self):
744
+                return self.viewset.get_update_view_success_url(self)
745
+
746
+        self.views[UPDATE_VIEW_TYPE] = self.get_decorated_view(UpdateView)
747
+
748
+    def generate_delete_view(self):
749
+        if self.is_view_generated(DELETE_VIEW_TYPE):
750
+            return
751
+
752
+        class DeleteView(self.get_base_view_mixin(), generic.DeleteView):
753
+            viewset = self
754
+            view_type = DELETE_VIEW_TYPE
755
+            template_name = self.delete_view_template_name
756
+            final_breadcrumb = 'Delete'
757
+
758
+            def dispatch(self, request, *args, **kwargs):
759
+                self.populate_object_cache(self.viewset.get_object(self))
760
+                return super(DeleteView, self).dispatch(request, *args, **kwargs)
761
+
762
+            def get_view_title(self):
763
+                return self.viewset.get_delete_view_title(self)
764
+
765
+            def get_success_url(self):
766
+                return self.viewset.get_delete_view_success_url(self)
767
+
768
+        if getattr(self, 'delete_object', None) is not None:
769
+            def delete(self, request, *args, **kwargs):
770
+                return self.viewset.delete_object(self, request, *args, **kwargs)
771
+
772
+            DeleteView.delete = delete
773
+
774
+        self.views[DELETE_VIEW_TYPE] = self.get_decorated_view(DeleteView)
775
+
776
+    @classmethod
777
+    def urls(cls):
778
+        viewset = cls()
779
+
780
+        lookup_regex = r'(?P<' + viewset.lookup_url_kwarg + '>' + viewset.lookup_url_kwarg_pattern + ')/' if viewset.lookup_regex is None else viewset.lookup_regex
781
+
782
+        patterns = []
783
+
784
+        def append_pattern(pattern, view_type):
785
+            view = viewset.views[view_type]
786
+            if view is not None:
787
+                patterns.append(url(pattern, view, name=view_type))
788
+
789
+        append_pattern(r'^$', LIST_VIEW_TYPE)
790
+        append_pattern(r'^create/$', CREATE_VIEW_TYPE)
791
+        append_pattern(lookup_regex + '$', DETAIL_VIEW_TYPE)
792
+        append_pattern(lookup_regex + 'update/$', UPDATE_VIEW_TYPE)
793
+        append_pattern(lookup_regex + 'delete/$', DELETE_VIEW_TYPE)
794
+
795
+        if not patterns:
796
+            # If a viewset functions only as a nested list view then it will
797
+            # not define any views of its own. We need at least one view URL
798
+            # to be defined in order to discover the viewset via link_viewsets,
799
+            # however, so we simply define a dummy one.
800
+            def not_found(request, *args, **kwargs):
801
+                raise Http404('Not found')
802
+            not_found.viewset = viewset
803
+            patterns.append(url(r'^$', not_found, name=NOT_FOUND_VIEW_TYPE))
804
+
805
+        return patterns
806
+
807
+    @staticmethod
808
+    def link_viewsets(views):
809
+        viewsets = {}
810
+
811
+        for view_function, url_pattern, view_name in views:
812
+            viewset = getattr(view_function, 'viewset', None)
813
+            if viewset is not None:
814
+                viewset_class = viewset.__class__
815
+                if viewset_class in viewsets:
816
+                    # TODO: we can remove this restriction by passing a name/ID
817
+                    # to the CrudViewset.urls() function to use in the viewset
818
+                    # constructor, and then specifying that name/ID instead of
819
+                    # the class for master or nested viewsets.
820
+                    # OR we could just create derived viewset classes for each
821
+                    # case.
822
+                    if viewset != viewsets[viewset_class]:
823
+                        raise ImproperlyConfigured("%s.urls() is called multiple times (see TODO in code)" % viewset_class.__name__)
824
+                else:
825
+                    viewset.url_name_prefix = CrudViewset.get_parent_url_name(view_name) + ':'
826
+                    viewsets[viewset_class] = viewset
827
+
828
+        for viewset_class, viewset in viewsets.items():
829
+            if viewset.master_viewset_class is not None:
830
+                # Sanity check.
831
+                if viewset.master_viewset is not None:
832
+                    raise RuntimeError("%s already has a master viewset" % viewset_class.__name__)
833
+
834
+                viewset.master_viewset = viewsets.get(viewset.master_viewset_class, None)
835
+                if viewset.master_viewset is None:
836
+                    raise ImproperlyConfigured("%s does not have an associated URL" % viewset.master_viewset_class.__name__)
837
+
838
+                if viewset.nested_list_view_index is not None:
839
+                    for i, nested_viewset in enumerate(viewset.master_viewset.nested_list_views):
840
+                        if viewset.nested_list_view_index < nested_viewset.nested_list_view_index:
841
+                            viewset.master_viewset.nested_list_views.insert(i, viewset)
842
+                            break
843
+                        elif viewset.nested_list_view_index == nested_viewset.nested_list_view_index:
844
+                            raise ImproperlyConfigured("%s and %s have the same value for nested_list_view_index", viewset.__class__.__name__, nested_viewset.__class__.__name__)
845
+                    else:
846
+                        viewset.master_viewset.nested_list_views.append(viewset)
847
+
848
+        for viewset in viewsets.values():
849
+            # Prevents unnecessary processing in
850
+            # remove_nested_list_view_query_params() if there are no nested list
851
+            # views.
852
+            if not viewset.nested_list_views:
853
+                viewset.nested_list_view_query_param_prefix = ''
854
+
855
+
856
+class Tables2ListViewMixin(object):
857
+    list_view_base_class = generic.TemplateView
858
+    list_view_template_name = 'crud/tables2_list_view.html'
859
+    detail_view_template_name = 'crud/tables2_detail_view.html'
860
+
861
+    tables2_class = None
862
+    tables2_context_variable_name = 'table'
863
+    tables2_paginator_query_string_variable_name = 'paginator_query_string'
864
+    tables2_template = 'django_tables2/bootstrap.html'
865
+    tables2_attributes = {}
866
+    tables2_meta_attributes = {}
867
+    tables2_page_field = 'p'
868
+    tables2_per_page_field = 'c'
869
+    tables2_order_by_field = 's'
870
+    tables2_clickable_rows = False
871
+    tables2_clickable_row_class = 'clickable-row'
872
+    tables2_clickable_row_view_type = DETAIL_VIEW_TYPE
873
+
874
+    def __init__(self, *args, **kwargs):
875
+        super(Tables2ListViewMixin, self).__init__(*args, **kwargs)
876
+
877
+        # For convenience.
878
+        if not self.is_view_enabled[DETAIL_VIEW_TYPE] and (self.tables2_clickable_row_view_type == DETAIL_VIEW_TYPE):
879
+            self.tables2_clickable_row_view_type = UPDATE_VIEW_TYPE
880
+
881
+        if self.tables2_class is None:
882
+            meta = type('Meta', (object,), self.tables2_meta_attributes)
883
+
884
+            attrs = copy.copy(self.tables2_attributes)
885
+            attrs['Meta'] = meta
886
+
887
+            self.tables2_class = type('Table', (django_tables2.Table,), attrs)
888
+
889
+        try:
890
+            my_attrs = copy.copy(self.tables2_class.Meta.attrs)
891
+        except AttributeError:
892
+            my_attrs = {}
893
+
894
+        my_attrs['class'] = my_attrs.get('class', 'table table-bordered table-striped table-hover')
895
+
896
+        class DerivedTable(self.tables2_class):
897
+            class Meta(self.tables2_class.Meta):
898
+                model = self.model
899
+                template = self.tables2_template
900
+                page_field = self.list_view_query_param_prefix + self.tables2_page_field
901
+                per_page_field = self.list_view_query_param_prefix + self.tables2_per_page_field
902
+                order_by_field = self.list_view_query_param_prefix + self.tables2_order_by_field
903
+                attrs = my_attrs
904
+
905
+        # This will create an instance variable containing the class and
906
+        # leave the original class variable unchanged.
907
+        self.tables2_class = DerivedTable
908
+
909
+    def add_list_view_context_data(self, view, GET, context):
910
+        super(Tables2ListViewMixin, self).add_list_view_context_data(view, GET, context)
911
+
912
+        # Sanity check: get_queryset() will not have been called yet as we
913
+        # are using a TemplateView instead of a ListView.
914
+        if 'object_list' in context:
915
+            raise RuntimeError("Context contains an 'object_list' key")
916
+
917
+        if self.filter_class is None:
918
+            queryset = self.get_queryset(view)
919
+        else:
920
+            context['filter'] = self.filter_class(GET, self.get_queryset(view), prefix=self.filter_query_param_prefix)
921
+            queryset = context['filter'].qs
922
+
923
+        table_kwargs = {}
924
+        if self.tables2_clickable_rows:
925
+            table_kwargs['row_attrs'] = {
926
+                'class': self.tables2_clickable_row_class,
927
+                'data-href': lambda record: self.get_view_url(self.tables2_clickable_row_view_type, view, GET, record)
928
+            }
929
+
930
+        table = self.tables2_class(queryset, **table_kwargs)
931
+        django_tables2.RequestConfig(view.request).configure(table)
932
+        table.viewset = self
933
+        table.view = view
934
+        table.GET = GET
935
+
936
+        paginator_query_params = GET.copy()
937
+        paginator_query_params.pop(table.page_field, None)
938
+
939
+        context[self.tables2_context_variable_name] = table
940
+        context[self.tables2_paginator_query_string_variable_name] = paginator_query_params.urlencode()
941
+
942
+        if self.is_view_enabled[CREATE_VIEW_TYPE]:
943
+            create_button_text = self.get_create_button_text()
944
+            if create_button_text is not None:
945
+                context['actions'].append(
946
+                    [
947
+                        {
948
+                            'type': 'button',
949
+                            'url': self.get_view_url(CREATE_VIEW_TYPE, view, GET),
950
+                            'text': create_button_text,
951
+                            'button_class': self.get_create_button_class()
952
+                        }
953
+                    ]
954
+                )

+ 0 - 0
feed_content/__init__.py


+ 8 - 0
feed_content/apps.py

@@ -0,0 +1,8 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.apps import AppConfig
5
+
6
+
7
+class FeedContentConfig(AppConfig):
8
+    name = 'feed_content'

+ 0 - 0
feed_content/management/__init__.py


+ 97 - 0
feed_content/management/commands/import_from_excel.py

@@ -0,0 +1,97 @@
1
+from django.core.management.base import BaseCommand, CommandError
2
+import xlrd
3
+from ... import models
4
+from pick import pick
5
+
6
+class SheetWrapper(object):
7
+    def __init__(self, sheet):
8
+        self.sheet = sheet
9
+
10
+    def __str__(self):
11
+        return self.sheet.name
12
+
13
+def import_sheet(sheet, category):
14
+    start_row = None
15
+    start_col = None
16
+    for row in range(sheet.nrows):
17
+        for col in range(sheet.ncols):
18
+            cell = sheet.cell(row, col)
19
+            if cell.ctype == xlrd.XL_CELL_TEXT:
20
+                if cell.value.strip().lower() == 'day':
21
+                    start_row = row + 1
22
+                    start_col = col
23
+                    break
24
+
25
+    if start_row is None:
26
+        raise RuntimeError("No 'Day' column found in sheet %s" % sheet.name)
27
+    if start_row >= sheet.nrows:
28
+        raise RuntimeError("'Day' column in sheet %s contains no data" % sheet.name)
29
+    if start_col >= sheet.ncols:
30
+        raise RuntimeError("No columns found to the right of the 'Day' column in sheet %s" % sheet.name)
31
+
32
+    expected_day = 1
33
+    end_row = None
34
+    feed_items = []
35
+    for row in range(start_row, sheet.nrows):
36
+        cell = sheet.cell(row, start_col)
37
+        if cell.ctype != xlrd.XL_CELL_NUMBER:
38
+            break
39
+        day = int(cell.value)
40
+        if day != expected_day:
41
+            raise RuntimeError("'Day' column in sheet %s contains an unexpected value: %s (expecting %s)" % (sheet.name, day, expected_day))
42
+
43
+        cell = sheet.cell(row, start_col + 1)
44
+        if cell.ctype != xlrd.XL_CELL_TEXT:
45
+            raise RuntimeError("No text found for day %s in sheet %s" % (day, sheet.name))
46
+
47
+        feed_items.append({'day': day, 'message_text': cell.value})
48
+        expected_day += 1
49
+        end_row = row
50
+
51
+    if end_row + 1 < sheet.nrows:
52
+        cell = sheet.cell(end_row + 1, start_col)
53
+        if cell.ctype != xlrd.XL_CELL_EMPTY:
54
+            raise RuntimeError("'Day' column in sheet %s contains a non-numerical value: %s" % (sheet.name, cell.value))
55
+
56
+    option, index = pick(['No', 'Yes'], title='You are about to import days %s to %s\nfrom sheet %s\ninto category %s.\nContinue?' % (feed_items[0]['day'], feed_items[-1]['day'], sheet.name, category.name))
57
+    if index == 0:
58
+        return
59
+
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
+        )
67
+
68
+class Command(BaseCommand):
69
+    help = 'Imports feed items from an Excel spreedsheet.'
70
+
71
+    def add_arguments(self, parser):
72
+        parser.add_argument('file_name')
73
+        
74
+    def handle(self, *args, **options):
75
+        file_name = options['file_name']
76
+
77
+        category_choices = ['>> Quit']
78
+        for category in models.FeedCategory.objects.order_by('name'):
79
+            category_choices.append(category)
80
+
81
+        book = xlrd.open_workbook(file_name)
82
+        sheet_choices = ['>> Quit']
83
+        for sheet in book.sheets():
84
+            sheet_choices.append(SheetWrapper(sheet))
85
+
86
+        while True:
87
+            option, index = pick(sheet_choices, title='Select the worksheet to be imported:')
88
+            if index == 0:
89
+                break
90
+            sheet = sheet_choices[index].sheet
91
+
92
+            option, index = pick(category_choices, title='Select the destination category:')
93
+            if index == 0:
94
+                break
95
+            category = category_choices[index]
96
+
97
+            import_sheet(sheet, category)

+ 46 - 0
feed_content/migrations/0001_initial.py

@@ -0,0 +1,46 @@
1
+# -*- coding: utf-8 -*-
2
+# Generated by Django 1.11.1 on 2017-05-29 11:18
3
+from __future__ import unicode_literals
4
+
5
+from django.db import migrations, models
6
+import django.db.models.deletion
7
+
8
+
9
+class Migration(migrations.Migration):
10
+
11
+    initial = True
12
+
13
+    dependencies = [
14
+    ]
15
+
16
+    operations = [
17
+        migrations.CreateModel(
18
+            name='FeedCategory',
19
+            fields=[
20
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21
+                ('name', models.CharField(max_length=50, unique=True)),
22
+                ('description', models.CharField(blank=True, max_length=255)),
23
+            ],
24
+            options={
25
+                'verbose_name': 'Category',
26
+                'verbose_name_plural': 'Categories',
27
+            },
28
+        ),
29
+        migrations.CreateModel(
30
+            name='FeedItem',
31
+            fields=[
32
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
33
+                ('day_number', models.PositiveIntegerField()),
34
+                ('message_text', models.CharField(max_length=160)),
35
+                ('feed_category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='feed_content.FeedCategory')),
36
+            ],
37
+            options={
38
+                'verbose_name': 'Item',
39
+                'verbose_name_plural': 'Items',
40
+            },
41
+        ),
42
+        migrations.AlterUniqueTogether(
43
+            name='feeditem',
44
+            unique_together=set([('feed_category', 'day_number')]),
45
+        ),
46
+    ]

+ 20 - 0
feed_content/migrations/0002_feedcategory_days_required.py

@@ -0,0 +1,20 @@
1
+# -*- coding: utf-8 -*-
2
+# Generated by Django 1.11.1 on 2017-05-29 13:41
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', '0001_initial'),
12
+    ]
13
+
14
+    operations = [
15
+        migrations.AddField(
16
+            model_name='feedcategory',
17
+            name='days_required',
18
+            field=models.PositiveIntegerField(default=366),
19
+        ),
20
+    ]

+ 0 - 0
feed_content/migrations/__init__.py


+ 98 - 0
feed_content/models.py

@@ -0,0 +1,98 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.db import models
5
+from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
6
+from django.db.utils import IntegrityError
7
+from django.utils.text import Truncator
8
+import re
9
+
10
+#import logging
11
+#logger = logging.getLogger('console')
12
+
13
+unique_constraint_re = re.compile(r'^UNIQUE\s+constraint\s+failed:(.*)$', re.IGNORECASE)
14
+
15
+class ModelEx(models.Model):
16
+    class Meta:
17
+        abstract = True
18
+
19
+    def get_duplicate_key_error_dict(self, fields):
20
+        return {NON_FIELD_ERRORS: 'Duplicate value for %s' % ', '.join(fields)}
21
+
22
+    def save(self, *args, **kwargs):
23
+        try:
24
+            super(ModelEx, self).save(*args, **kwargs)
25
+        except IntegrityError as e:
26
+            match = unique_constraint_re.search(e.message)
27
+            if match:
28
+                fields = [x.strip().split('.')[-1] for x in match.group(1).split(',')]
29
+                raise ValidationError(self.get_duplicate_key_error_dict(fields))
30
+            else:
31
+                raise
32
+
33
+class FeedCategory(ModelEx):
34
+    name = models.CharField(max_length=50, unique=True)
35
+    description = models.CharField(max_length=255, blank=True)
36
+    days_required = models.PositiveIntegerField(default=366)
37
+
38
+    class Meta:
39
+        verbose_name = 'Category'
40
+        verbose_name_plural = 'Categories'
41
+
42
+    def get_duplicate_key_error_dict(self, fields):
43
+        if fields[0] == 'name':
44
+            return {'name': 'There is already a category with this name'}
45
+        else:
46
+            return super(FeedCategory, self).get_duplicate_key_error_dict(fields)
47
+
48
+    @property
49
+    def status(self):
50
+        last_day = 0
51
+        total_missing = 0
52
+        missing_days = []
53
+
54
+        for item in FeedItem.objects.filter(feed_category=self).order_by('feed_category_id', 'day_number'):
55
+            day = item.day_number
56
+            missing_count = day - last_day - 1
57
+            total_missing += missing_count
58
+
59
+            # The unique key and order_by on FeedItem ensure that missing_count
60
+            # cannot be less than 0.
61
+            if missing_count == 1:
62
+                missing_days.append(str(day - 1))
63
+            elif missing_count > 1:
64
+                missing_days.append('{0}-{1}'.format(last_day + 1, day - 1))
65
+
66
+            last_day = day
67
+
68
+        missing_count = self.days_required - last_day
69
+        if missing_count > 0:
70
+            total_missing += missing_count
71
+            if missing_count == 1:
72
+                missing_days.append(str(self.days_required))
73
+            elif missing_count > 1:
74
+                missing_days.append('{0}-{1}'.format(last_day + 1, self.days_required))
75
+
76
+        if missing_days:
77
+            return "WARNING: Missing {0} day{1}: {2}".format(total_missing, '' if total_missing == 1 else 's', ', '.join(missing_days))
78
+        else:
79
+            return "Complete"
80
+
81
+    def __unicode__(self):
82
+        return self.name
83
+
84
+class FeedItem(ModelEx):
85
+    feed_category = models.ForeignKey(FeedCategory, on_delete=models.CASCADE)
86
+    day_number = models.PositiveIntegerField()
87
+    message_text = models.CharField(max_length=160)
88
+
89
+    class Meta:
90
+        verbose_name = 'Item'
91
+        verbose_name_plural = 'Items'
92
+        unique_together = ('feed_category', 'day_number')
93
+
94
+    def get_duplicate_key_error_dict(self, fields):
95
+        return {'day_number': 'There is already an item with this day number'}
96
+
97
+    def __unicode__(self):
98
+        return u'{0}: {1}'.format(self.day_number, Truncator(self.message_text).chars(20))

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

@@ -0,0 +1,20 @@
1
+{% extends "crud/tables2_detail_view.html" %}
2
+
3
+{% block detail_view_content %}
4
+<div class="row">
5
+  <div class="col-xs-2"><label>Name</label></div>
6
+  <div class="col-xs-10">{{ object.name }}</div>
7
+</div>
8
+<div class="row">
9
+  <div class="col-xs-2"><label>Description</label></div>
10
+  <div class="col-xs-10">{{ object.description }}</div>
11
+</div>
12
+<div class="row">
13
+  <div class="col-xs-2"><label>Days Required</label></div>
14
+  <div class="col-xs-10">{{ object.days_required }}</div>
15
+</div>
16
+<div class="row">
17
+  <div class="col-xs-2"><label>Status</label></div>
18
+  <div class="col-xs-10">{{ object.status }}</div>
19
+</div>
20
+{% endblock %}

+ 16 - 0
feed_content/templates/feed_content/feed_item_detail_view.html

@@ -0,0 +1,16 @@
1
+{% extends "crud/tables2_detail_view.html" %}
2
+
3
+{% block detail_view_content %}
4
+<div class="row">
5
+  <div class="col-xs-2"><label>Category</label></div>
6
+  <div class="col-xs-10">{{ object.feed_category.name }}</div>
7
+</div>
8
+<div class="row">
9
+  <div class="col-xs-2"><label>Day number</label></div>
10
+  <div class="col-xs-10">{{ object.day_number }}</div>
11
+</div>
12
+<div class="row">
13
+  <div class="col-xs-2"><label>Message text</label></div>
14
+  <div class="col-xs-10">{{ object.message_text }}</div>
15
+</div>
16
+{% endblock %}

+ 7 - 0
feed_content/urls.py

@@ -0,0 +1,7 @@
1
+from django.conf.urls import url, include
2
+import viewsets
3
+
4
+urlpatterns = [
5
+    url('^(?P<feed_category_id>\d+)/items/', include(viewsets.FeedItemViewset.urls(), namespace='feed_item')),
6
+    url('^', include(viewsets.FeedCategoryViewset.urls())),
7
+]

+ 6 - 0
feed_content/views.py

@@ -0,0 +1,6 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.shortcuts import render
5
+
6
+# Create your views here.

+ 118 - 0
feed_content/viewsets.py

@@ -0,0 +1,118 @@
1
+from django import forms
2
+from django.db.models import Q
3
+from base.viewsets import BaseTable, BaseViewset
4
+from crud.tables2_columns import CrudLinkColumn
5
+from django_tables2 import Column, LinkColumn, A
6
+from django.shortcuts import reverse
7
+from django.utils.text import mark_safe
8
+import django_filters
9
+from . import models
10
+
11
+class FeedCategoryFilterSet(django_filters.FilterSet):
12
+    search_expr = django_filters.CharFilter(label='Search', method='filter_search_expr')
13
+
14
+    class Meta:
15
+        fields = ['search_expr']
16
+        model = models.FeedCategory
17
+
18
+    def filter_search_expr(self, queryset, name, value):
19
+        return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value))
20
+
21
+class FeedCategoryTable(BaseTable):
22
+    rss_url = Column(accessor='pk', verbose_name="RSS URL")
23
+    status = Column(orderable=False)
24
+    delete = CrudLinkColumn('delete', text='', verbose_name='', orderable=False, attrs={'a': {'class': 'btn btn-danger btn-xs glyphicon glyphicon-remove', 'role': 'button'}})
25
+
26
+    class Meta(BaseTable.Meta):
27
+        fields = ('name', 'description', 'rss_url', 'days_required', 'status', 'delete')
28
+        order_by = ('name',)
29
+
30
+    def render_rss_url(self, value, bound_column):
31
+        rss_url = bound_column._table.view.request.build_absolute_uri(reverse('rss:daily', kwargs={'feed_category_id': value}))
32
+        return mark_safe('<a href="{0}">{0}</a>'.format(rss_url))
33
+
34
+class FeedCategoryForm(forms.ModelForm):
35
+    name = forms.CharField(widget=forms.TextInput(attrs={'autofocus': True}))
36
+
37
+    class Meta:
38
+        model = models.FeedCategory
39
+        fields = ('name', 'description', 'days_required')
40
+
41
+class FeedCategoryViewset(BaseViewset):
42
+    lookup_url_kwarg = 'feed_category_id'
43
+
44
+    filter_class = FeedCategoryFilterSet
45
+    form_class = FeedCategoryForm
46
+    tables2_class = FeedCategoryTable
47
+    tables2_clickable_rows = True
48
+
49
+    detail_view_title = 'Category'
50
+    detail_view_template_name = 'feed_content/feed_category_detail_view.html'
51
+
52
+    def get_queryset(self, view):
53
+        return models.FeedCategory.objects.all()
54
+
55
+
56
+class FeedItemFilterSet(django_filters.FilterSet):
57
+    day_number = django_filters.NumberFilter(label='Day')
58
+    message_text = django_filters.CharFilter(label='Text', lookup_expr='icontains')
59
+
60
+    class Meta:
61
+        fields = ('day_number', 'message_text')
62
+        model = models.FeedItem
63
+
64
+class FeedItemTable(BaseTable):
65
+    delete = CrudLinkColumn('delete', text='', verbose_name='', orderable=False, attrs={'a': {'class': 'btn btn-danger btn-xs glyphicon glyphicon-remove', 'role': 'button'}})
66
+
67
+    class Meta(BaseTable.Meta):
68
+        fields = ('day_number', 'message_text', 'delete')
69
+        order_by = ('day_number',)
70
+
71
+class FeedItemForm(forms.ModelForm):
72
+    day_number = forms.IntegerField(required=False, help_text='<ul><li>Leave blank to use the next available day number</li></ul>', widget=forms.TextInput(attrs={'autofocus': True}))
73
+    message_text = forms.CharField(widget=forms.Textarea(attrs={'cols': 40, 'rows': 5}))
74
+
75
+    class Meta:
76
+        fields = ('day_number', 'message_text')
77
+        model = models.FeedItem
78
+
79
+    def clean_day_number(self):
80
+        day_number = self.cleaned_data['day_number']
81
+        if day_number is None:
82
+            feed_category = self.view.get_cached_object(self.view.viewset.master_viewset)
83
+            queryset = models.FeedItem.objects.filter(feed_category=feed_category).order_by('-day_number')
84
+            if self.instance.id is not None:
85
+                queryset = queryset.exclude(id=self.instance.id)
86
+            items = queryset[:1]
87
+            if items:
88
+                return items[0].day_number + 1
89
+            else:
90
+                return 1
91
+        return day_number
92
+
93
+class FeedItemViewset(BaseViewset):
94
+    master_viewset_class = FeedCategoryViewset
95
+    nested_list_view_index = 0
96
+    view_url_kwarg_names = BaseViewset.derive_view_url_kwarg_names(master_viewset_class)
97
+
98
+    lookup_url_kwarg = 'feed_item_id'
99
+
100
+    filter_class = FeedItemFilterSet
101
+    form_class = FeedItemForm
102
+    tables2_class = FeedItemTable
103
+    tables2_clickable_rows = True
104
+
105
+    list_view_title = 'Messages'
106
+    detail_view_title = 'Message'
107
+    detail_view_template_name = 'feed_content/feed_item_detail_view.html'
108
+
109
+    def get_queryset(self, view):
110
+        if view is None:
111
+            return models.FeedItem.objects.none()
112
+        return models.FeedItem.objects.filter(feed_category_id=view.kwargs['feed_category_id'])
113
+
114
+    def get_master_object(self, view, instance):
115
+        return instance.feed_category
116
+
117
+    def create_view_form_valid(self, view, form):
118
+        form.instance.feed_category = view.get_cached_object(self.master_viewset)

+ 22 - 0
manage.py

@@ -0,0 +1,22 @@
1
+#!/usr/bin/env python
2
+import os
3
+import sys
4
+
5
+if __name__ == "__main__":
6
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sms_feed.settings")
7
+    try:
8
+        from django.core.management import execute_from_command_line
9
+    except ImportError:
10
+        # The above import may fail for some other reason. Ensure that the
11
+        # issue is really that Django is missing to avoid masking other
12
+        # exceptions on Python 2.
13
+        try:
14
+            import django
15
+        except ImportError:
16
+            raise ImportError(
17
+                "Couldn't import Django. Are you sure it's installed and "
18
+                "available on your PYTHONPATH environment variable? Did you "
19
+                "forget to activate a virtual environment?"
20
+            )
21
+        raise
22
+    execute_from_command_line(sys.argv)

+ 8 - 0
requirements.in

@@ -0,0 +1,8 @@
1
+django
2
+django-bootstrap3
3
+django-extensions
4
+django-filter
5
+django-tables2
6
+pick
7
+pip-tools
8
+xlrd

+ 18 - 0
requirements.txt

@@ -0,0 +1,18 @@
1
+#
2
+# This file is autogenerated by pip-compile
3
+# To update, run:
4
+#
5
+#    pip-compile --output-file requirements.txt requirements.in
6
+#
7
+click==6.7                # via pip-tools
8
+django-bootstrap3==8.2.3
9
+django-extensions==1.7.9
10
+django-filter==1.0.4
11
+django-tables2==1.6.1
12
+django==1.11.1
13
+first==2.0.1              # via pip-tools
14
+pick==0.6.3
15
+pip-tools==1.9.0
16
+pytz==2017.2              # via django
17
+six==1.10.0               # via django-extensions, pip-tools
18
+xlrd==1.0.0

+ 0 - 0
rss/__init__.py


+ 64 - 0
rss/feeds.py

@@ -0,0 +1,64 @@
1
+from django.contrib.syndication.views import Feed
2
+from django.shortcuts import get_object_or_404
3
+from django.urls import reverse
4
+from django.utils.timezone import utc
5
+from datetime import datetime
6
+from feed_content.models import FeedCategory, FeedItem
7
+from models import DailyFeedItemLogEntry
8
+
9
+class DailyFeed(Feed):
10
+    def get_object(self, request, feed_category_id):
11
+        return get_object_or_404(FeedCategory, pk=feed_category_id)
12
+
13
+    def title(self, obj):
14
+        return obj.name
15
+
16
+    def link(self, obj):
17
+        return reverse('rss:daily', kwargs={'feed_category_id': obj.pk})
18
+
19
+    def description(self, obj):
20
+        return obj.description
21
+
22
+    def items(self, obj):
23
+        now = datetime.now()
24
+        day_of_year = now.timetuple().tm_yday
25
+
26
+        try:
27
+            feed_item = FeedItem.objects.get(feed_category=obj, day_number=day_of_year)
28
+        except FeedItem.DoesNotExist:
29
+            feed_item = None
30
+
31
+        if feed_item is None:
32
+            return []
33
+
34
+        log_entry, created = DailyFeedItemLogEntry.objects.get_or_create(
35
+            request_date=now.date(),
36
+            feed_category=obj,
37
+            feed_item=feed_item,
38
+            defaults={
39
+                'category_name': obj.name,
40
+                'message_text': feed_item.message_text,
41
+                'request_count': 1
42
+            }
43
+        )
44
+
45
+        if not created:
46
+            log_entry.request_count += 1
47
+            log_entry.save()
48
+
49
+        return [log_entry]
50
+
51
+    def item_title(self, item):
52
+        return 'Content'
53
+
54
+    def item_description(self, item):
55
+        return item.message_text
56
+
57
+    def item_guid(self, item):
58
+        return str(item.pk)
59
+
60
+    def item_link(self, item):
61
+        return reverse('rss:daily', kwargs={'feed_category_id': item.feed_category.pk})
62
+
63
+    def item_pubdate(self, item):
64
+        return datetime.combine(item.request_date, datetime.min.time())

+ 34 - 0
rss/migrations/0001_initial.py

@@ -0,0 +1,34 @@
1
+# -*- coding: utf-8 -*-
2
+# Generated by Django 1.11.1 on 2017-05-29 14:50
3
+from __future__ import unicode_literals
4
+
5
+from django.db import migrations, models
6
+import django.db.models.deletion
7
+
8
+
9
+class Migration(migrations.Migration):
10
+
11
+    initial = True
12
+
13
+    dependencies = [
14
+        ('feed_content', '0002_feedcategory_days_required'),
15
+    ]
16
+
17
+    operations = [
18
+        migrations.CreateModel(
19
+            name='DailyFeedItemLogEntry',
20
+            fields=[
21
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22
+                ('request_date', models.DateField()),
23
+                ('request_count', models.PositiveIntegerField()),
24
+                ('category_name', models.CharField(max_length=50)),
25
+                ('message_text', models.CharField(max_length=160)),
26
+                ('feed_category', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='feed_content.FeedCategory')),
27
+                ('feed_item', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='feed_content.FeedItem')),
28
+            ],
29
+        ),
30
+        migrations.AlterUniqueTogether(
31
+            name='dailyfeeditemlogentry',
32
+            unique_together=set([('request_date', 'feed_category', 'feed_item')]),
33
+        ),
34
+    ]

+ 0 - 0
rss/migrations/__init__.py


+ 21 - 0
rss/models.py

@@ -0,0 +1,21 @@
1
+# -*- coding: utf-8 -*-
2
+from __future__ import unicode_literals
3
+
4
+from django.db import models
5
+from feed_content.models import ModelEx, FeedCategory, FeedItem
6
+
7
+#import logging
8
+#logger = logging.getLogger('console')
9
+
10
+class DailyFeedItemLogEntry(ModelEx):
11
+    request_date = models.DateField()
12
+    request_count = models.PositiveIntegerField()
13
+    feed_category = models.ForeignKey(FeedCategory, db_constraint=False)
14
+    category_name = models.CharField(max_length=50)
15
+    feed_item = models.ForeignKey(FeedItem, db_constraint=False)
16
+    message_text = models.CharField(max_length=160)
17
+
18
+    class Meta:
19
+        verbose_name = 'Log Entry'
20
+        verbose_name_plural = 'Log Entries'
21
+        unique_together = ('request_date', 'feed_category', 'feed_item')

+ 7 - 0
rss/urls.py

@@ -0,0 +1,7 @@
1
+from django.conf.urls import url, include
2
+from . import feeds, viewsets
3
+
4
+urlpatterns = [
5
+    url('^daily/logs/', include(viewsets.DailyFeedItemLogEntryViewset.urls(), namespace='daily_logs')),
6
+    url('^daily/(?P<feed_category_id>\d+)$', feeds.DailyFeed(), name='daily')
7
+]

+ 32 - 0
rss/viewsets.py

@@ -0,0 +1,32 @@
1
+from base.viewsets import BaseTable, BaseViewset
2
+from django_tables2 import Column, DateColumn
3
+import django_filters
4
+from . import models
5
+
6
+class DailyFeedItemLogEntryFilterSet(django_filters.FilterSet):
7
+    from_date = django_filters.DateFilter(name='request_date', label='From', lookup_expr='gte')
8
+    to_date = django_filters.DateFilter(name='request_date', label='To', lookup_expr='lte')
9
+    category_name = django_filters.CharFilter(label='Category', lookup_expr='icontains')
10
+    message_text = django_filters.CharFilter(label='Message', lookup_expr='icontains')
11
+
12
+    class Meta:
13
+        fields = ['from_date', 'to_date', 'category_name', 'message_text']
14
+        model = models.DailyFeedItemLogEntry
15
+
16
+class DailyFeedItemLogEntryTable(BaseTable):
17
+    request_date = DateColumn(verbose_name="Date")
18
+    category_name = Column(verbose_name="Category")
19
+    request_count = Column(verbose_name="Requests")
20
+
21
+    class Meta(BaseTable.Meta):
22
+        fields = ('request_date', 'category_name', 'message_text', 'request_count')
23
+        order_by = ('-request_date', 'category_name')
24
+
25
+class DailyFeedItemLogEntryViewset(BaseViewset):
26
+    filter_class = DailyFeedItemLogEntryFilterSet
27
+    tables2_class = DailyFeedItemLogEntryTable
28
+
29
+    exclude_views = ['create', 'detail', 'update', 'delete']
30
+
31
+    def get_queryset(self, view):
32
+        return models.DailyFeedItemLogEntry.objects.all()

+ 0 - 0
sms_feed/__init__.py


+ 161 - 0
sms_feed/settings.py

@@ -0,0 +1,161 @@
1
+"""
2
+Django settings for sms_feed project.
3
+
4
+Generated by 'django-admin startproject' using Django 1.11.1.
5
+
6
+For more information on this file, see
7
+https://docs.djangoproject.com/en/1.11/topics/settings/
8
+
9
+For the full list of settings and their values, see
10
+https://docs.djangoproject.com/en/1.11/ref/settings/
11
+"""
12
+
13
+import os
14
+
15
+
16
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
17
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+
19
+
20
+# Quick-start development settings - unsuitable for production
21
+# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
22
+
23
+# SECURITY WARNING: keep the secret key used in production secret!
24
+SECRET_KEY = None # OVERRIDE
25
+
26
+# SECURITY WARNING: don't run with debug turned on in production!
27
+DEBUG = False
28
+
29
+ALLOWED_HOSTS = []
30
+
31
+
32
+# Application definition
33
+
34
+INSTALLED_APPS = [
35
+    'django.contrib.auth',
36
+    'django.contrib.contenttypes',
37
+    'django.contrib.sessions',
38
+    'django.contrib.messages',
39
+    'django.contrib.staticfiles',
40
+    'django_extensions',
41
+    'django_filters',
42
+    'django_tables2',
43
+    'bootstrap3',
44
+    'base',
45
+    'accounts',
46
+    'crud',
47
+    'feed_content',
48
+    'rss',
49
+]
50
+
51
+MIDDLEWARE = [
52
+    'django.middleware.security.SecurityMiddleware',
53
+    'django.contrib.sessions.middleware.SessionMiddleware',
54
+    'django.middleware.common.CommonMiddleware',
55
+    'django.middleware.csrf.CsrfViewMiddleware',
56
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
57
+    'django.contrib.messages.middleware.MessageMiddleware',
58
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
59
+]
60
+
61
+ROOT_URLCONF = 'sms_feed.urls'
62
+
63
+TEMPLATES = [
64
+    {
65
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
66
+        'DIRS': [],
67
+        'APP_DIRS': True,
68
+        'OPTIONS': {
69
+            'context_processors': [
70
+                'django.template.context_processors.debug',
71
+                'django.template.context_processors.request',
72
+                'django.contrib.auth.context_processors.auth',
73
+                'django.contrib.messages.context_processors.messages',
74
+            ],
75
+        },
76
+    },
77
+]
78
+
79
+WSGI_APPLICATION = 'sms_feed.wsgi.application'
80
+
81
+
82
+# Database
83
+# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
84
+
85
+DATABASES = {} # OVERRIDE
86
+
87
+
88
+# Password validation
89
+# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
90
+
91
+AUTH_PASSWORD_VALIDATORS = [
92
+    {
93
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
94
+    },
95
+    {
96
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
97
+    },
98
+    {
99
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
100
+    },
101
+    {
102
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
103
+    },
104
+]
105
+
106
+
107
+# Internationalization
108
+# https://docs.djangoproject.com/en/1.11/topics/i18n/
109
+
110
+LANGUAGE_CODE = 'en-us'
111
+
112
+TIME_ZONE = 'UTC'
113
+
114
+USE_I18N = True
115
+
116
+USE_L10N = True
117
+
118
+USE_TZ = True
119
+
120
+# Static files (CSS, JavaScript, Images)
121
+# https://docs.djangoproject.com/en/1.11/howto/static-files/
122
+
123
+STATIC_URL = '/static/'
124
+STATIC_ROOT = os.path.join(BASE_DIR, 'static')
125
+
126
+AUTH_USER_MODEL = 'accounts.user'
127
+SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 14
128
+
129
+LOGGING = {
130
+    'version': 1,
131
+    'disable_existing_loggers': False,
132
+    'formatters': {
133
+        'verbose': {
134
+            'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
135
+        },
136
+        'simple': {
137
+            'format': '%(levelname)s %(message)s'
138
+        },
139
+    },
140
+    'handlers': {
141
+        'console': {
142
+            'level': 'DEBUG',
143
+            'class': 'logging.StreamHandler',
144
+            'formatter': 'simple'
145
+        },
146
+    },
147
+    'loggers': {
148
+        'console': {
149
+            'handlers': ['console'],
150
+            'level': 'DEBUG',
151
+            'propagate': True
152
+        },
153
+        'fanmode': {
154
+            'handlers': ['console'],
155
+            'level': 'DEBUG',
156
+            'propagate': True
157
+        }
158
+    }
159
+}
160
+
161
+from settings_local import *

+ 8 - 0
sms_feed/url_patterns.py

@@ -0,0 +1,8 @@
1
+from django_extensions.management.commands.show_urls import Command
2
+
3
+def flatten_url_patterns(url_patterns):
4
+    views = []
5
+    for view_function, url_pattern, view_name in Command().extract_views_from_urlpatterns(url_patterns):
6
+        url_pattern = url_pattern[:1] + url_pattern[1:].replace('^', '') if len(url_pattern) > 0 else url_pattern
7
+        views.append((view_function, url_pattern, view_name))
8
+    return views

+ 12 - 0
sms_feed/urls.py

@@ -0,0 +1,12 @@
1
+from django.conf.urls import url, include
2
+from url_patterns import flatten_url_patterns
3
+from crud.viewsets import CrudViewset
4
+
5
+urlpatterns = [
6
+    url(r'^accounts/', include('accounts.urls')),
7
+    url(r'^feed_content/', include('feed_content.urls', namespace='feed_content')),
8
+    url(r'^rss/', include('rss.urls', namespace='rss')),
9
+    url(r'^', include('base.urls')),
10
+]
11
+
12
+CrudViewset.link_viewsets(flatten_url_patterns(urlpatterns))

+ 16 - 0
sms_feed/wsgi.py

@@ -0,0 +1,16 @@
1
+"""
2
+WSGI config for sms_feed project.
3
+
4
+It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+For more information on this file, see
7
+https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
8
+"""
9
+
10
+import os
11
+
12
+from django.core.wsgi import get_wsgi_application
13
+
14
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sms_feed.settings")
15
+
16
+application = get_wsgi_application()