diff --git a/apps/core/management/commands/init_groups.py b/apps/core/management/commands/init_groups.py index acf489d8a457a4072f562bc08f49ed5840cc4bc4..96b0e96331d048728c700dbc79ce8ab922d54878 100644 --- a/apps/core/management/commands/init_groups.py +++ b/apps/core/management/commands/init_groups.py @@ -3,13 +3,20 @@ from django.contrib.auth.models import Group, Permission groups = ["jedi", "padawan"] permissions = { - "jedi": [], - "padawans": [] + "jedi": [ + "can_change_status", "can_change_priority", "can_vote", "can_edit" + ], + "padawan": ["can_vote", "add_article"] } class Command(BaseCommand): - help = "Adds initial groups for the application (jedi and padawans)" + help = "Adds initial groups for the application (jedis and padawans)" def handle(self, *args, **options): - pass + + for g in groups: + print("Creating group '{}'".format(g)) + new_group, created = Group.objects.get_or_create(name=g) + for p in permissions[g]: + new_group.permissions.add(Permission.objects.get(codename=p)) diff --git a/apps/rp/api/mixins.py b/apps/rp/api/mixins.py new file mode 100644 index 0000000000000000000000000000000000000000..91646bde600b2d12827e851c063b6a795b353e5a --- /dev/null +++ b/apps/rp/api/mixins.py @@ -0,0 +1,51 @@ +from django_fsm import has_transition_perm, can_proceed +from rest_framework.decorators import detail_route +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response + +import inspect + + +def get_transition_viewset_method(model, transition_name): + @detail_route(methods=['post']) + def inner_func(self, request, pk=None, *args, **kwargs): + object = self.get_object() + transition_method = getattr(object, transition_name) + + if not can_proceed(transition_method): + raise PermissionDenied + + if not has_transition_perm(transition_method, request.user): + raise PermissionDenied + + if 'by' in inspect.getargspec(transition_method).args: + transition_method(*args, by=request.user, **kwargs) + else: + transition_method(*args, **kwargs) + + if self.save_after_transition: + object.save() + + serializer = self.get_serializer(object) + return Response(serializer.data) + + return inner_func + + +def get_viewset_transition_actions_mixin(model): + """ + Automatically generate methods for Django REST Framework from transition + rules. + """ + instance = model() + + class Mixin(object): + save_after_transition = True + + transitions = instance.get_all_status_transitions() + transition_names = set(x.name for x in transitions) + for transition_name in transition_names: + setattr(Mixin, transition_name, + get_transition_viewset_method(model, transition_name)) + + return Mixin diff --git a/apps/rp/api/views.py b/apps/rp/api/views.py index fc118f215e1ed9f33822a8dd0174c509483199a2..91c696b5ed5ab06e84b5bf4617f48a254a4ee008 100644 --- a/apps/rp/api/views.py +++ b/apps/rp/api/views.py @@ -1,61 +1,12 @@ from rest_framework import viewsets -from rest_framework.decorators import detail_route -from rest_framework.response import Response from rp.models import Article from .serializers import ArticleSerializer +from .mixins import get_viewset_transition_actions_mixin +ArticleMixin = get_viewset_transition_actions_mixin(Article) -class ArticleViewSet(viewsets.ModelViewSet): + +class ArticleViewSet(ArticleMixin, viewsets.ModelViewSet): queryset = Article.objects.all() serializer_class = ArticleSerializer - - def response_serialized_object(self, object): - return Response(self.serializer_class(object).data) - - @detail_route(methods=["post"], url_path="publish") - def publish(self, request, pk=None): - article = self.get_object() - article.publish() - article.save() - return self.response_serialized_object(article) - - @detail_route(methods=["post"], url_path="reject") - def reject(self, request, pk=None): - article = self.get_object() - article.reject() - article.save() - return self.response_serialized_object(article) - - @detail_route(methods=["post"], url_path="recover") - def recover(self, request, pk=None): - article = self.get_object() - article.recover() - article.save() - return self.response_serialized_object(article) - - @detail_route(methods=["post"], url_path="upvote") - def upvote(self, request, pk=None): - article = self.get_object() - article.upvote(user_object=request.user.username) - return self.response_serialized_object(article) - - @detail_route(methods=["post"], url_path="downvote") - def downvote(self, request, pk=None): - article = self.get_object() - article.downvote(user_object=request.user.username) - return self.response_serialized_object(article) - - @detail_route(methods=["post"], url_path="priority/on") - def priority_on(self, request, pk=None, priority=True): - article = self.get_object() - article.set_priority(True) - article.save() - return self.response_serialized_object(article) - - @detail_route(methods=["post"], url_path="priority/off") - def priority_off(self, request, pk=None, priority=True): - article = self.get_object() - article.set_priority(False) - article.save() - return self.response_serialized_object(article) diff --git a/apps/rp/migrations/0014_auto_20170501_1611.py b/apps/rp/migrations/0014_auto_20170501_1611.py new file mode 100644 index 0000000000000000000000000000000000000000..3eb468e36c1767f84a57160ed2e3e53670c22454 --- /dev/null +++ b/apps/rp/migrations/0014_auto_20170501_1611.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-05-01 16:11 +from __future__ import unicode_literals + +from django.db import migrations +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ('rp', '0013_auto_20170428_1341'), + ] + + operations = [ + migrations.AlterModelOptions( + name='article', + options={'permissions': (('can_change_status', 'Can change article status'), ('can_change_priority', 'Can change article priority'), ('can_vote', 'Can vote articles'), ('can_edit', 'Can edit articles')), 'verbose_name': 'Article', 'verbose_name_plural': 'Articles'}, + ), + migrations.AlterField( + model_name='article', + name='status', + field=django_fsm.FSMField(choices=[('NEW', 'New'), ('DRAFT', 'Draft'), ('PUBLISHED', 'Published'), ('REJECTED', 'Rejected')], default='NEW', max_length=50, protected=True), + ), + ] diff --git a/apps/rp/models/article.py b/apps/rp/models/article.py index 75fe3e051c04ec23bbf60aa2416df263d6aba39e..ac25976199cf6f4073e040554d3e2a08840a8ee4 100644 --- a/apps/rp/models/article.py +++ b/apps/rp/models/article.py @@ -75,6 +75,7 @@ class Article(VoteMixin): ("can_change_status", "Can change article status"), ("can_change_priority", "Can change article priority"), ("can_vote", "Can vote articles"), + ("can_edit", "Can edit articles") ) def __str__(self): @@ -82,7 +83,8 @@ class Article(VoteMixin): # Finite state logic - @transition(field=status, source='DRAFT', target='PUBLISHED') + @transition(field=status, source='DRAFT', target='PUBLISHED', + permission="can_change_status") def publish(self): self.published_at = datetime.now() @@ -98,14 +100,19 @@ class Article(VoteMixin): @transition(field=status, source='DRAFT', target='DRAFT', permission="can_change_priority") - def set_priority(self, value): - self.priority = value + def set_priority(self): + self.priority = True + + @transition(field=status, source='DRAFT', target='DRAFT', + permission="can_change_priority") + def unset_priority(self): + self.priority = False @transition(field=status, source='DRAFT', target='DRAFT') @transition(field=status, source='NEW', target=RETURN_VALUE('NEW', 'DRAFT'), permission="can_vote") - def upvote(self, user_object): - super(Article, self).upvote(user_object) + def upvote(self, by=None): + super(Article, self).upvote(by) if self.und_score >= ARTICLE_SCORE_THRESHOLD: return 'DRAFT' else: @@ -114,8 +121,8 @@ class Article(VoteMixin): @transition(field=status, source='NEW', target='NEW', permission="can_vote") @transition(field=status, source='DRAFT', target='DRAFT', permission="can_vote") - def downvote(self, user_object): - super(Article, self).downvote(user_object) + def downvote(self, by=None): + super(Article, self).downvote(by) # Content extraction diff --git a/apps/rp/templates/rp/article_list.html b/apps/rp/templates/rp/article_list.html index bef1da3d51b9d4d0828c9f8e6a01bbee99e5de4c..262a01791462cfeb755e8fb05cd91fd980b71a9f 100644 --- a/apps/rp/templates/rp/article_list.html +++ b/apps/rp/templates/rp/article_list.html @@ -218,8 +218,13 @@ } function call_priority(id, flag) { - var url = "/api/articles/" + id + "/priority/" + (flag ? "on/" : "off/"); - $.post(url, {'priority': flag}, function response(data) { + if(flag) { + var url = "/api/articles/" + id + "/set_priority/"; + } else { + var url = "/api/articles/" + id + "/unset_priority/"; + } + + $.post(url, function response(data) { $("#priority_" + id).toggleClass("fa-star").toggleClass("fa-star-o"); }); } diff --git a/apps/rp/urls.py b/apps/rp/urls.py index cb1a236bc361f047e004b5bf2fe797895be86416..ce39af78d9284ebc5c3b4709a857a71e4c7bde9a 100644 --- a/apps/rp/urls.py +++ b/apps/rp/urls.py @@ -1,30 +1,32 @@ +from django.contrib.auth.decorators import login_required from django.conf.urls import url + from rp.views.articles import ArticleListFlux, ArticleEdit, ArticleDetailView urlpatterns = [ url( r"^article/list/(?P\w+)", - ArticleListFlux.as_view(), + login_required(ArticleListFlux.as_view()), name="article-list" ), url( r"^article/list", - ArticleListFlux.as_view(), + login_required(ArticleListFlux.as_view()), name="article-list" ), url( r"^article/edit/(?P\d+)", - ArticleEdit.as_view(), + login_required(ArticleEdit.as_view()), name="article-edit" ), url( r"^article/view/(?P\d+)", - ArticleDetailView.as_view(), + login_required(ArticleDetailView.as_view()), name="article-view" ), url( r"^article/preview/(?P\d+)", - ArticleDetailView.as_view(preview=True), + login_required(ArticleDetailView.as_view(preview=True)), name="article-preview" ) ] diff --git a/apps/rp/views/articles.py b/apps/rp/views/articles.py index 3534c332b9bb5bdc31ebbf14339c9d793782cd1a..82600968a7b068a26bdadc14bb4efa42604ea09d 100644 --- a/apps/rp/views/articles.py +++ b/apps/rp/views/articles.py @@ -5,6 +5,8 @@ from django.utils.translation import ugettext_lazy as _ from django.urls import reverse, reverse_lazy from django import forms +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin + from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field, Div, HTML from crispy_forms.bootstrap import AppendedText @@ -47,8 +49,10 @@ class ArticleDetailView(DetailView): return context -class ArticleEdit(UpdateView): +class ArticleEdit(PermissionRequiredMixin, UpdateView): model = Article + permission_required = 'can_edit' + fields = ['screenshot', 'url', 'lang', 'title', 'tags', 'extracts'] success_url = reverse_lazy("rp:article-list") diff --git a/apps/userprofile/admin.py b/apps/userprofile/admin.py index 782016899cb483e968857933cf4d718fd9e66c0b..e6dee2854a7d70771dedd16009e0f05f05f22ea2 100644 --- a/apps/userprofile/admin.py +++ b/apps/userprofile/admin.py @@ -22,13 +22,19 @@ class UserProfileInline(admin.StackedInline): class UserProfileAdmin(UserAdmin): inlines = [UserProfileInline] + + list_display = ("username", "email", "first_name", "last_name", "is_staff", "get_groups") + fieldsets = ( (None, {"fields": ("username", "password")}), (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), (_("Permissions"), { - "fields": ("is_active", "is_staff", "is_superuser")}), + "fields": ("is_active", "is_staff", "is_superuser", "groups")}), ) + def get_groups(self, obj): + return ", ".join(sorted([g.name for g in obj.groups.all()])) + get_groups.short_description = _("Groups") admin.site.unregister(User) admin.site.register(User, UserProfileAdmin) diff --git a/project/settings/api.py b/project/settings/api.py index 59064a878ec944656cc6294fc3ee89da8757dca1..8a753f6f72a43f5cb0fe23b77a7721e89b411a83 100644 --- a/project/settings/api.py +++ b/project/settings/api.py @@ -6,4 +6,8 @@ REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": ("rest_framework.pagination." "PageNumberPagination"), "PAGE_SIZE": 20, + + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", + ) } diff --git a/project/settings/auth.py b/project/settings/auth.py index 538f33acef537598386cb8e7d38b9e98022dd3d1..078c4e456316bc937eb5a189fe5a4d5899787021 100644 --- a/project/settings/auth.py +++ b/project/settings/auth.py @@ -4,7 +4,7 @@ User registration and login related settings AUTH_USER_MODEL = "auth.User" EXTENDED_USER_MODEL = "userprofile.Profile" -LOGIN_URL = "login" +LOGIN_URL = "/accounts/login" AUTHENTICATION_BACKENDS = [