Merge pull request #1002 from ae-utbm/perms

Permissions refactor
This commit is contained in:
Bartuccio Antoine 2025-01-14 17:58:22 +01:00 committed by GitHub
commit 80940765fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1003 additions and 455 deletions

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from accounting.models import ClubAccount, Company from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema from accounting.schemas import ClubAccountSchema, CompanySchema
from core.api_permissions import CanAccessLookup from core.auth.api_permissions import CanAccessLookup
@api_controller("/lookup", permissions=[CanAccessLookup]) @api_controller("/lookup", permissions=[CanAccessLookup])

View File

@ -17,6 +17,7 @@ import collections
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
@ -44,15 +45,15 @@ from accounting.widgets.select import (
) )
from club.models import Club from club.models import Club
from club.widgets.select import AutoCompleteSelectClub from club.widgets.select import AutoCompleteSelectClub
from core.models import User from core.auth.mixins import (
from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
TabedViewMixin,
) )
from core.models import User
from core.views.forms import SelectDate, SelectFile from core.views.forms import SelectDate, SelectFile
from core.views.mixins import TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling from counter.models import Counter, Product, Selling
@ -86,12 +87,13 @@ class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView): class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins).""" """Create an accounting type (for the admins)."""
model = SimplifiedAccountingType model = SimplifiedAccountingType
fields = ["label", "accounting_type"] fields = ["label", "accounting_type"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "accounting.add_simplifiedaccountingtype"
# Accounting types # Accounting types
@ -113,12 +115,13 @@ class AccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
class AccountingTypeCreateView(CanCreateMixin, CreateView): class AccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins).""" """Create an accounting type (for the admins)."""
model = AccountingType model = AccountingType
fields = ["code", "label", "movement_type"] fields = ["code", "label", "movement_type"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "accounting.add_accountingtype"
# BankAccount views # BankAccount views

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Club from club.models import Club
from club.schemas import ClubSchema from club.schemas import ClubSchema
from core.api_permissions import CanAccessLookup from core.auth.api_permissions import CanAccessLookup
@api_controller("/club") @api_controller("/club")

View File

@ -25,6 +25,7 @@
import csv import csv
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum from django.db.models import Sum
@ -49,17 +50,15 @@ from com.views import (
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.models import PageRev from core.auth.mixins import (
from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
DetailFormView,
PageEditViewBase,
TabedViewMixin,
UserIsRootMixin,
) )
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin
from counter.models import Selling from counter.models import Selling
@ -474,13 +473,14 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "props" current_tab = "props"
class ClubCreateView(CanCreateMixin, CreateView): class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin).""" """Create a club (for the Sith admin)."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent"] fields = ["name", "unix_name", "parent"]
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView): class MembershipSetOldView(CanEditMixin, DetailView):
@ -512,12 +512,13 @@ class MembershipSetOldView(CanEditMixin, DetailView):
) )
class MembershipDeleteView(UserIsRootMixin, DeleteView): class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
"""Delete a membership (for admins only).""" """Delete a membership (for admins only)."""
model = Membership model = Membership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "club.delete_membership"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id}) return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})

View File

@ -68,7 +68,10 @@ class NewsQuerySet(models.QuerySet):
""" """
if user.has_perm("com.view_unmoderated_news"): if user.has_perm("com.view_unmoderated_news"):
return self return self
return self.filter(Q(is_moderated=True) | Q(author_id=user.id)) q_filter = Q(is_moderated=True)
if user.is_authenticated:
q_filter |= Q(author_id=user.id)
return self.filter(q_filter)
class News(models.Model): class News(models.Model):
@ -149,8 +152,12 @@ class News(models.Model):
self.author_id == user.id or user.has_perm("com.change_news") self.author_id == user.id or user.has_perm("com.change_news")
) )
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user: User):
return self.is_moderated or user.has_perm("com.view_unmoderated_news") return (
self.is_moderated
or user.has_perm("com.view_unmoderated_news")
or (user.is_authenticated and self.author_id == user.id)
)
def news_notification_callback(notif): def news_notification_callback(notif):

View File

@ -159,13 +159,13 @@ class TestNews(TestCase):
def test_news_viewer(self): def test_news_viewer(self):
"""Test that moderated news can be viewed by anyone """Test that moderated news can be viewed by anyone
and not moderated news only by com admins. and not moderated news only by com admins and by their author.
""" """
# by default a news isn't moderated # by default news aren't moderated
assert self.new.can_be_viewed_by(self.com_admin) assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.author)
assert not self.new.can_be_viewed_by(self.sli) assert not self.new.can_be_viewed_by(self.sli)
assert not self.new.can_be_viewed_by(self.anonymous) assert not self.new.can_be_viewed_by(self.anonymous)
assert not self.new.can_be_viewed_by(self.author)
self.new.is_moderated = True self.new.is_moderated = True
self.new.save() self.new.save()

View File

@ -27,10 +27,7 @@ from smtplib import SMTPRecipientsRefused
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
AccessMixin,
PermissionRequiredMixin,
)
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max from django.db.models import Max
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
@ -47,13 +44,13 @@ from club.models import Club, Mailing
from com.calendar import IcsCalendar from com.calendar import IcsCalendar
from com.forms import NewsDateForm, NewsForm, PosterForm from com.forms import NewsDateForm, NewsForm, PosterForm
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.models import User from core.auth.mixins import (
from core.views import (
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
QuickNotifMixin, PermissionOrAuthorRequiredMixin,
TabedViewMixin,
) )
from core.models import User
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object
@ -169,24 +166,17 @@ class NewsCreateView(PermissionRequiredMixin, CreateView):
return init return init
class NewsUpdateView(UpdateView): class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News model = News
form_class = NewsForm form_class = NewsForm
template_name = "com/news_edit.jinja" template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
permission_required = "com.edit_news"
def dispatch(self, request, *args, **kwargs):
if (
not request.user.has_perm("com.edit_news")
and self.get_object().author != request.user
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
self.object = form.save() response = super().form_valid(form) # Does the saving part
IcsCalendar.make_internal() IcsCalendar.make_internal()
return super().form_valid(form) return response
def get_date_form_kwargs(self) -> dict[str, Any]: def get_date_form_kwargs(self) -> dict[str, Any]:
"""Get initial data for NewsDateForm""" """Get initial data for NewsDateForm"""
@ -209,7 +199,7 @@ class NewsUpdateView(UpdateView):
} }
class NewsDeleteView(PermissionRequiredMixin, DeleteView): class NewsDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
model = News model = News
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"

View File

@ -11,10 +11,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing from club.models import Mailing
from core.api_permissions import ( from core.auth.api_permissions import CanAccessLookup, CanView
CanAccessLookup,
CanView,
)
from core.models import Group, SithFile, User from core.models import Group, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,

0
core/auth/__init__.py Normal file
View File

View File

@ -3,7 +3,8 @@
Some permissions are global (like `IsInGroup` or `IsRoot`), Some permissions are global (like `IsInGroup` or `IsRoot`),
and some others are per-object (like `CanView` or `CanEdit`). and some others are per-object (like `CanView` or `CanEdit`).
Examples: Example:
```python
# restrict all the routes of this controller # restrict all the routes of this controller
# to subscribed users # to subscribed users
@api_controller("/foo", permissions=[IsSubscriber]) @api_controller("/foo", permissions=[IsSubscriber])
@ -33,6 +34,7 @@ Examples:
] ]
def bar_delete(self, bar_id: int): def bar_delete(self, bar_id: int):
# ... # ...
```
""" """
from typing import Any from typing import Any

287
core/auth/mixins.py Normal file
View File

@ -0,0 +1,287 @@
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# - Sli <antoine@bartuccio.fr>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from __future__ import annotations
import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.views.generic.base import View
if TYPE_CHECKING:
from django.db.models import Model
from core.models import User
def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object properties else False
Example:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
"""Can the user edit the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object else False
Example:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj: Any, user: User) -> bool:
"""Can the user see the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to see object else False
Example:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
return can_edit(obj, user)
class GenericContentPermissionMixinBuilder(View):
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties.
Attributes:
raised_error: permission to be raised
"""
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
def dispatch(self, request, *arg, **kwargs):
if hasattr(self, "get_object") and callable(self.get_object):
self.object = self.get_object()
if not self.get_permission_function(self.object, request.user):
raise self.raised_error
return super().dispatch(request, *arg, **kwargs)
# If we get here, it's a ListView
queryset = self.get_queryset()
l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
if not l_id and queryset.count() != 0:
raise self.raised_error
self._get_queryset = self.get_queryset
def get_qs(self2):
return self2._get_queryset().filter(id__in=l_id)
self.get_queryset = types.MethodType(get_qs, self)
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def __init_subclass__(cls, **kwargs):
warnings.warn(
f"{cls.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def __init__(self, *args, **kwargs):
warnings.warn(
f"{self.__class__.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to edit this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to view this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.was_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the object author.
This mixin can be used in combination with `DetailView`,
or another base class that implements the `get_object` method.
Example:
In the following code, a user will be able
to edit news if he has the `com.change_news` permission
or if he tries to edit his own news :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
author_field = "author"
permission_required = "com.change_news"
```
This is more or less equivalent to :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if not (
user.has_perm("com.change_news")
or self.object.author == request.user
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
```
"""
author_field: LiteralString = "author"
def has_permission(self):
if not hasattr(self, "get_object"):
raise ImproperlyConfigured(
f"{self.__class__.__name__} is missing the "
"get_object attribute. "
f"Define {self.__class__.__name__}.get_object, "
"or inherit from a class that implement it (like DetailView)"
)
if super().has_permission():
return True
if self.request.user.is_anonymous:
return False
obj: Model = self.get_object()
if not self.author_field.endswith("_id"):
# getting the related model could trigger a db query
# so we will rather get the foreign value than
# the object itself.
self.author_field += "_id"
author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id

View File

@ -894,7 +894,9 @@ Welcome to the wiki page!
public_group = Group.objects.create(name="Public") public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers") subscribers = Group.objects.create(name="Subscribers")
subscribers.permissions.add(*list(perms.filter(codename__in=["add_news"]))) subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcommentreport"]))
)
old_subscribers = Group.objects.create(name="Old subscribers") old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add( old_subscribers.permissions.add(
*list( *list(

View File

@ -30,7 +30,7 @@
{% if m.can_be_edited_by(user) %} {% if m.can_be_edited_by(user) %}
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td> <td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
{% endif %} {% endif %}
{% if user.is_root %} {% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td> <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %} {% endif %}
</tr> </tr>
@ -59,7 +59,7 @@
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td> <td>{{ m.end_date }}</td>
{% if user.is_root %} {% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td> <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %} {% endif %}
</tr> </tr>

View File

@ -327,12 +327,9 @@ http://git.an
class TestUserTools: class TestUserTools:
def test_anonymous_user_unauthorized(self, client): def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to the tools page.""" """An anonymous user shouldn't have access to the tools page."""
response = client.get(reverse("core:user_tools")) url = reverse("core:user_tools")
assertRedirects( response = client.get(url)
response, assertRedirects(response, expected_url=reverse("core:login") + f"?next={url}")
expected_url="/login?next=%2Fuser%2Ftools%2F",
target_status_code=301,
)
@pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"]) @pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
def test_page_is_working(self, client, username): def test_page_is_working(self, client, username):

View File

@ -22,15 +22,6 @@
# #
# #
import types
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import (
ImproperlyConfigured,
PermissionDenied,
)
from django.http import ( from django.http import (
HttpResponseForbidden, HttpResponseForbidden,
HttpResponseNotFound, HttpResponseNotFound,
@ -38,12 +29,10 @@ from django.http import (
) )
from django.shortcuts import render from django.shortcuts import render
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic.base import View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from sentry_sdk import last_event_id from sentry_sdk import last_event_id
from core.models import User
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -65,254 +54,6 @@ def internal_servor_error(request):
return HttpResponseServerError(render(request, "core/500.jinja")) return HttpResponseServerError(render(request, "core/500.jinja"))
def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object properties else False
Examples:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
"""Can the user edit the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object else False
Examples:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj: Any, user: User) -> bool:
"""Can the user see the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to see object else False
Examples:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
return can_edit(obj, user)
class GenericContentPermissionMixinBuilder(View):
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties.
Attributes:
raised_error: permission to be raised
"""
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
def dispatch(self, request, *arg, **kwargs):
if hasattr(self, "get_object") and callable(self.get_object):
self.object = self.get_object()
if not self.get_permission_function(self.object, request.user):
raise self.raised_error
return super().dispatch(request, *arg, **kwargs)
# If we get here, it's a ListView
queryset = self.get_queryset()
l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
if not l_id and queryset.count() != 0:
raise self.raised_error
self._get_queryset = self.get_queryset
def get_qs(self2):
return self2._get_queryset().filter(id__in=l_id)
self.get_queryset = types.MethodType(get_qs, self)
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to edit this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to view this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class UserIsRootMixin(GenericContentPermissionMixinBuilder):
"""Allow only root admins.
Raises:
PermissionDenied: if the user isn't root
"""
@staticmethod
def permission_function(obj: Any, user: User):
return user.is_root
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.was_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class SubscriberMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required")
def get_current_tab(self):
if hasattr(self, "current_tab"):
return self.current_tab
raise ImproperlyConfigured("current_tab is required")
def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"):
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["tabs_title"] = self.get_tabs_title()
kwargs["current_tab"] = self.get_current_tab()
kwargs["list_of_tabs"] = self.get_list_of_tabs()
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class DetailFormView(SingleObjectMixin, FormView): class DetailFormView(SingleObjectMixin, FormView):
"""Class that allow both a detail view and a form view.""" """Class that allow both a detail view and a form view."""
@ -326,14 +67,6 @@ class DetailFormView(SingleObjectMixin, FormView):
return super().get_object() return super().get_object()
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)
# F403: those star-imports would be hellish to refactor # F403: those star-imports would be hellish to refactor
# E402: putting those import at the top of the file would also be difficult # E402: putting those import at the top of the file would also be difficult
from .files import * # noqa: F403 E402 from .files import * # noqa: F403 E402

View File

@ -33,14 +33,14 @@ from django.views.generic import DetailView, ListView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView from django.views.generic.edit import DeleteView, FormMixin, UpdateView
from core.models import Notification, SithFile, User from core.auth.mixins import (
from core.views import (
AllowFragment,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
can_view, can_view,
) )
from core.models import Notification, SithFile, User
from core.views.mixins import AllowFragment
from core.views.widgets.select import ( from core.views.widgets.select import (
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile, AutoCompleteSelectSithFile,

View File

@ -16,13 +16,15 @@
"""Views to manage Groups.""" """Views to manage Groups."""
from django import forms from django import forms
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin
from core.models import Group, User from core.models import Group, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView from core.views import DetailFormView
from core.views.widgets.select import AutoCompleteSelectMultipleUser from core.views.widgets.select import AutoCompleteSelectMultipleUser
# Forms # Forms
@ -73,13 +75,14 @@ class GroupEditView(CanEditMixin, UpdateView):
fields = ["name", "description"] fields = ["name", "description"]
class GroupCreateView(CanCreateMixin, CreateView): class GroupCreateView(PermissionRequiredMixin, CreateView):
"""Add a new Group.""" """Add a new Group."""
model = Group model = Group
queryset = Group.objects.filter(is_manually_manageable=True) queryset = Group.objects.filter(is_manually_manageable=True)
template_name = "core/create.jinja" template_name = "core/create.jinja"
fields = ["name", "description"] fields = ["name", "description"]
permission_required = "core.add_group"
class GroupTemplateView(CanEditMixin, DetailFormView): class GroupTemplateView(CanEditMixin, DetailFormView):

67
core/views/mixins.py Normal file
View File

@ -0,0 +1,67 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.views import View
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required")
def get_current_tab(self):
if hasattr(self, "current_tab"):
return self.current_tab
raise ImproperlyConfigured("current_tab is required")
def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"):
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["tabs_title"] = self.get_tabs_title()
kwargs["current_tab"] = self.get_current_tab()
kwargs["list_of_tabs"] = self.get_list_of_tabs()
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)

View File

@ -21,8 +21,13 @@ from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import LockError, Page, PageRev from core.models import LockError, Page, PageRev
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from core.views.forms import PageForm, PagePropForm from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput

View File

@ -54,14 +54,8 @@ from django.views.generic.dates import MonthMixin, YearMixin
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Gift, Preferences, User from core.models import Gift, Preferences, User
from core.views import (
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
)
from core.views.forms import ( from core.views.forms import (
GiftForm, GiftForm,
LoginForm, LoginForm,
@ -70,6 +64,7 @@ from core.views.forms import (
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
from counter.views.student_card import StudentCardFormView from counter.views.student_card import StudentCardFormView
from eboutic.models import Invoice from eboutic.models import Invoice

View File

@ -20,7 +20,7 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from counter.models import Counter, Product, ProductType from counter.models import Counter, Product, ProductType
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,

View File

@ -24,8 +24,8 @@ from django.utils import timezone
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin
from counter.forms import CounterEditForm, ProductEditForm from counter.forms import CounterEditForm, ProductEditForm
from counter.models import Counter, Product, ProductType, Refilling, Selling from counter.models import Counter, Product, ProductType, Refilling, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter

View File

@ -23,7 +23,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView from django.views.generic.edit import UpdateView
from core.views import CanViewMixin from core.auth.mixins import CanViewMixin
from counter.forms import CashSummaryFormBase from counter.forms import CashSummaryFormBase
from counter.models import ( from counter.models import (
CashRegisterSummary, CashRegisterSummary,

View File

@ -31,9 +31,9 @@ from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import CanViewMixin
from counter.forms import RefillForm from counter.forms import RefillForm
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer, Product, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter

View File

@ -18,7 +18,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView
from core.views import CanViewMixin from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm from counter.forms import EticketForm
from counter.models import Eticket, Selling from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin

View File

@ -22,7 +22,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.edit import FormMixin, ProcessFormView from django.views.generic.edit import FormMixin, ProcessFormView
from core.views import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views.forms import LoginForm from core.views.forms import LoginForm
from counter.forms import GetUserForm from counter.forms import GetUserForm
from counter.models import Counter from counter.models import Counter

View File

@ -19,7 +19,7 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View from django.views.generic.base import View
from core.views import TabedViewMixin from core.views.mixins import TabedViewMixin
class CounterAdminMixin(View): class CounterAdminMixin(View):

View File

@ -21,8 +21,8 @@ from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
from core.auth.mixins import can_edit
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import can_edit
from counter.forms import StudentCardForm from counter.forms import StudentCardForm
from counter.models import Customer, StudentCard from counter.models import Customer, StudentCard
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter

View File

@ -1 +0,0 @@
::: core.api_permissions

View File

@ -0,0 +1,32 @@
## Backend
::: core.auth.backends
handler: python
options:
heading_level: 3
members:
- SithModelBackend
## Mixins
::: core.auth.mixins
handler: python
options:
heading_level: 3
members:
- can_edit_prop
- can_edit
- can_view
- CanCreateMixin
- CanEditMixin
- CanViewMixin
- FormerSubscriberMixin
- PermissionOrAuthorRequiredMixin
## API Permissions
::: core.auth.api_permissions
handler: python
options:
heading_level: 3

View File

@ -157,7 +157,9 @@ il est automatiquement ajouté au groupe des membres
du club. du club.
Lorsqu'il quitte le club, il est retiré du groupe. Lorsqu'il quitte le club, il est retiré du groupe.
## Les principaux groupes utilisés ## Les groupes utilisés
### Groupes principaux
Les groupes les plus notables gérables par les administrateurs du site sont : Les groupes les plus notables gérables par les administrateurs du site sont :
@ -168,15 +170,61 @@ Les groupes les plus notables gérables par les administrateurs du site sont :
- `SAS admin` : les administrateurs du SAS - `SAS admin` : les administrateurs du SAS
- `Forum admin` : les administrateurs du forum - `Forum admin` : les administrateurs du forum
- `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs) - `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs)
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation
En plus de ces groupes, on peut noter : En plus de ces groupes, on peut noter :
- `Public` : tous les utilisateurs du site - `Public` : tous les utilisateurs du site.
- `Subscribers` : tous les cotisants du site Un utilisateur est automatiquement ajouté à ce group
- `Old subscribers` : tous les anciens cotisants lors de la création de son compte.
- `Subscribers` : tous les cotisants du site.
Les utilisateurs ne sont pas réellement ajoutés ce groupe ;
cependant, les utilisateurs cotisants sont implicitement
considérés comme membres du groupe lors de l'appel
à la méthode `User.has_perm`.
- `Old subscribers` : tous les anciens cotisants.
Un utilisateur est automatiquement ajouté à ce groupe
lors de sa première cotisation
!!!note "Utilisation du groupe Public"
Le groupe Public est un groupe particulier.
Tout le monde faisant partie de ce groupe
(même les utilisateurs non-connectés en sont implicitement
considérés comme membres),
il ne doit pas être utilisé pour résoudre les
permissions d'une vue.
En revanche, il est utile pour attribuer une ressource
à tout le monde.
Par exemple, un produit avec le groupe de vente Public
est considéré comme achetable par tous utilisateurs.
S'il n'avait eu aucun group de vente, il n'aurait
été accessible à personne.
### Groupes de club
Chaque club est associé à deux groupes :
le groupe des membres et le groupe du bureau.
Lorsqu'un utilisateur rejoint un club, il est automatiquement
ajouté au groupe des membres.
S'il rejoint le club en tant que membre du bureau,
il est également ajouté au groupe du bureau.
Lorsqu'un utilisateur quitte le club, il est automatiquement
retiré des groupes liés au club.
S'il quitte le bureau, mais reste dans le club,
il est retiré du groupe du bureau, mais reste dans le groupe des membres.
### Groupes de ban
Les groupes de ban sont une catégorie de groupes à part,
qui ne sont pas stockés dans la même table
et qui ne sont pas gérés sur la même interface
que les autres groupes.
Les groupes de ban existants sont les suivants :
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation

View File

@ -1,15 +1,292 @@
## Les permissions ## Objectifs du système de permissions
Le fonctionnement de l'AE ne permet pas d'utiliser le système de permissions Les permissions attendues sur le site sont relativement spécifiques.
intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le L'accès à une ressource peut se faire selon un certain nombre
plus simple à l'époque était de concevoir un système maison afin de se calquer de paramètres différents :
sur ce que faisait l'ancien site.
### Protéger un modèle `L'état de la ressource`
: Certaines ressources
sont visibles par tous les cotisants (voire tous les utilisateurs),
à condition qu'elles aient passé une étape de modération.
La visibilité des ressources non-modérées nécessite des permissions
supplémentaires.
La gestion des permissions se fait directement par modèle. `L'appartenance à un groupe`
Il existe trois niveaux de permission : : Les groupes Root, Admin Com, Admin SAS, etc.
sont associés à des jeux de permissions.
Par exemple, les membres du groupe Admin SAS ont tous les droits sur
les ressources liées au SAS : ils peuvent voir,
créer, éditer, supprimer et éventuellement modérer
des images, des albums, des identifications de personnes...
Il en va de même avec les admins Com pour la communication,
les admins pédagogie pour le guide des UEs et ainsi de suite.
Quant aux membres du groupe Root, ils ont tous les droits
sur toutes les ressources du site.
`Le statut de la cotisation`
: Les non-cotisants n'ont presque aucun
droit sur les ressources du site (ils peuvent seulement en voir une poignée),
les anciens cotisants peuvent voir un grand nombre de ressources
et les cotisants actuels ont la plupart des droits qui ne sont
pas liés à un club ou à l'administration du site.
`L'appartenance à un club`
: Être dans un club donne le droit
de voir la plupart des ressources liées au club dans lequel ils
sont ; être dans le bureau du club donne en outre des droits
d'édition et de création sur ces ressources.
`Être l'auteur ou le possesseur d'une ressource`
: Certaines ressources, comme les nouvelles,
enregistrent l'utilisateur qui les a créées ;
ce dernier a les droits de voir, de modifier et éventuellement
de supprimer ses ressources, quand bien même
elles ne seraient pas visibles pour les utilisateurs normaux
(par exemple, parce qu'elles ne sont pas encore modérées.)
Le système de permissions inclus par défaut dans django
permet de modéliser aisément l'accès à des ressources au niveau
de la table.
Ainsi, il n'est pas compliqué de gérer les permissions liées
aux groupes d'administration.
Cependant, une surcouche est nécessaire dès lors que l'on veut
gérer les droits liés à une ligne en particulier
d'une table de la base de données.
Nous essayons le plus possible de nous tenir aux fonctionnalités
de django, sans pour autant hésiter à nous rabattre sur notre
propre surcouche dès lors que les permissions attendues
deviennent trop spécifiques pour être gérées avec juste django.
!!!info "Un peu d'histoire"
Les permissions du site n'ont pas toujours été gérées
avec un mélange de fonctionnalités de django et de notre
propre code.
Pendant très longtemps, seule la surcouche était utilisée,
ce qui menait souvent à des vérifications de droits
inefficaces et à une gestion complexe de certaines
parties qui auraient pu être manipulées beaucoup plus simplement.
En plus de ça, les permissions liées à la plupart
des groupes se faisait de manière hardcodée :
plutôt que d'associer un groupe à un jeu de permission
et de faire une jointure en db sur les groupes de l'utilisateur
ayant cette permissions,
on conservait la clef primaire du groupe dans la config
et on vérifiait en dur dans le code que l'utilisateur
était un des groupes voulus.
Ce système possédait le triple désavantage de prendre énormément
de temps, d'être extrêmement limité (de fait, si tout est hardcodé,
on est obligé d'avoir le moins de groupes possibles pour que ça reste
gérable) et d'être désespérément dangereux (par exemple : fin novembre 2024,
une erreur dans le code a donné les accès à la création des cotisations
à tout le monde ; mi-octobre 2019, le calcul des permissions des etickets
pouvait faire tomber le site, cf.
[ce topic du forum](https://ae.utbm.fr/forum/topic/17943/?page=1msg2277272))
## Accès à toutes les ressources d'une table
Gérer ce genre d'accès (par exemple : voir toutes les nouvelles
ou pouvoir supprimer n'importe quelle photo)
est exactement le problème que le système de permissions de django résout.
Nous utilisons donc ce système dans ce genre de situations.
!!!note
Nous décrivons ci-dessous l'usage que nous faisons du système
de permissions de django,
mais la seule source d'information complète et pleinement fiable
sur le fonctionnement réel de ce système est
[la documentation de django](https://docs.djangoproject.com/fr/stable/topics/auth/default/).
### Permissions d'un modèle
Par défaut, django crée quatre permissions pour chaque table de la base de données :
- `add_<nom de la table>` : créer un objet dans cette table
- `view_<nom de la table>` : voir le contenu de la table
- `change_<nom de la table>` : éditer des objets de la table
- `delete_<nom de la table>` : supprimer des objets de la table
Ces permissions sont créées au même moment que le modèle.
Si la table existe en base de données, ces permissions existent aussi.
Il est également possible de rajouter nos propres permissions,
directement dans les options Meta du modèle.
Par exemple, prenons le modèle suivant :
```python
from django.db import models
class News(models.Model):
# ...
class Meta:
permissions = [
("moderate_news", "Can moderate news"),
("view_unmoderated_news", "Can view non-moderated news"),
]
```
Ce dernier aura les permissions : `view_news`, `add_news`, `change_news`,
`delete_news`, `moderate_news` et `view_unmoderated_news`.
### Utilisation des permissions d'un modèle
Pour vérifier qu'un utilisateur a une permission,
on utilise les fonctions suivantes :
- `User.has_perm(perm)` : retourne `True` si l'utilisateur
a la permission voulue, sinon `False`
- `User.has_perms([perm_a, perm_b, perm_c])` : retourne `True` si l'utilisateur
a toutes les permissions voulues, sinon `False`.
Ces fonctions attendent un string suivant le format :
`<nom de l'application>.<nom de la permission>`.
Par exemple, la permission pour vérifier qu'un utilisateur
peut modérer une nouvelle sera : `com.moderate_news`.
Ces fonctions sont utilisables aussi bien dans les templates Jinja
que dans le code Python :
=== "Jinja"
```jinja
{% if user.has_perm("com.moderate_news") %}
<form method="post" action="{{ url("com:news_moderate", news_id=387) }}">
<input type="submit" value="Modérer" />
</form>
{% endif %}
```
=== "Python"
```python
from com.models import News
from core.models import User
user = User.objects.get(username="bibou")
news = News.objects.get(id=387)
if user.has_perm("com.moderate_news"):
news.is_moderated = True
news.save()
else:
raise PermissionDenied
```
Pour utiliser ce système de permissions dans une class-based view
(c'est-à-dire la plus grande partie de nos vues),
Django met à disposition `PermissionRequiredMixin`,
qui restreint l'accès à la vue aux utilisateurs ayant
la ou les permissions requises.
Pour les vues sous forme de fonction, il y a le décorateur
`permission_required`.
=== "Class-Based View"
```python
from com.models import News
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import redirect
from django.urls import reverse
from django.views import View
from django.views.generic.detail import SingleObjectMixin
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.moderate_news"
# On peut aussi fournir plusieurs permissions, par exemple :
# permission_required = ["com.moderate_news", "com.delete_news"]
def post(self, request, *args, **kwargs):
# Si nous sommes ici, nous pouvons être certains que l'utilisateur
# a la permission requise
obj = self.get_object()
obj.is_moderated = True
obj.save()
return redirect(reverse("com:news_list"))
```
=== "Function-based view"
```python
from com.models import News
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views.decorators.http import require_POST
@permission_required("com.moderate_news")
@require_POST
def moderate_news(request, news_id: int):
# Si nous sommes ici, nous pouvons être certains que l'utilisateur
# a la permission requise
news = get_object_or_404(News, id=news_id)
news.is_moderated = True
news.save()
return redirect(reverse("com:news_list"))
```
## Accès à des éléments en particulier
### Accès à l'auteur de la ressource
Dans ce genre de cas, on peut identifier trois acteurs possibles :
- les administrateurs peuvent accéder à toutes les ressources,
y compris non-modérées
- l'auteur d'une ressource non-modérée peut y accéder
- Les autres utilisateurs ne peuvent pas voir les ressources
non-modérées dont ils ne sont pas l'auteur
Dans ce genre de cas, on souhaite donc accorder l'accès aux
utilisateurs qui ont la permission globale, selon le système
décrit plus haut, ou bien à l'auteur de la ressource.
Pour cela, nous avons le mixin `PermissionOrAuthorRequired`.
Ce dernier va effectuer les mêmes vérifications que `PermissionRequiredMixin`
puis, si l'utilisateur n'a pas la permission requise, vérifier
s'il est l'auteur de la ressource.
```python
from com.models import News
from core.auth.mixins import PermissionOrAuthorRequiredMixin
from django.views.generic import UpdateView
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.change_news"
author_field = "author" # (1)!
```
1. Nom du champ du modèle utilisé comme clef étrangère vers l'auteur.
Par exemple, ici, la permission sera accordée si
l'utilisateur connecté correspond à l'utilisateur
désigné par `News.author`.
### Accès en fonction de règles plus complexes
Tout ce que nous avons décrit précédemment permet de couvrir
la plupart des cas simples.
Cependant, il arrivera souvent que les permissions attendues soient
plus complexes.
Dans ce genre de cas, on rentre entièrement dans notre surcouche.
#### Implémentation dans les modèles
La gestion de ce type de permissions se fait directement par modèle.
Il en existe trois niveaux :
- Éditer des propriétés de l'objet - Éditer des propriétés de l'objet
- Éditer certaines valeurs l'objet - Éditer certaines valeurs l'objet
@ -47,28 +324,43 @@ Voici un exemple d'implémentation de ce système :
from core.models import User, Group from core.models import User, Group
# Utilisation de la protection par fonctions
class Article(models.Model): class Article(models.Model):
title = models.CharField(_("title"), max_length=100) title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content")) content = models.TextField(_("content"))
# Donne ou non les droits d'édition des propriétés de l'objet def is_owned_by(self, user): # (1)!
# Un utilisateur dans le bureau AE aura tous les droits sur cet objet
def is_owned_by(self, user):
return user.is_board_member return user.is_board_member
# Donne ou non les droits d'édition de l'objet def can_be_edited_by(self, user): # (2)!
# L'objet ne sera modifiable que par un utilisateur cotisant
def can_be_edited_by(self, user):
return user.is_subscribed return user.is_subscribed
# Donne ou non les droits de vue de l'objet def can_be_viewed_by(self, user): # (3)!
# Ici, l'objet n'est visible que par un utilisateur connecté
def can_be_viewed_by(self, user):
return not user.is_anonymous return not user.is_anonymous
``` ```
1. Donne ou non les droits d'édition des propriétés de l'objet.
Ici, un utilisateur dans le bureau AE aura tous les droits sur cet objet
2. Donne ou non les droits d'édition de l'objet
Ici, l'objet ne sera modifiable que par un utilisateur cotisant
3. Donne ou non les droits de vue de l'objet
Ici, l'objet n'est visible que par un utilisateur connecté
!!!note
Dans cet exemple, nous utilisons des permissions très simples
pour que vous puissiez constater le squelette de ce système,
plutôt que la logique de validation dans ce cas particulier.
En réalité, il serait ici beaucoup plus approprié de
donner les permissions `com.delete_article` et
`com.change_article_properties` (en créant ce dernier
s'il n'existe pas encore) au groupe du bureau AE,
de donner également la permission `com.change_article`
au groupe `Cotisants` et enfin de restreindre l'accès
aux vues d'accès aux articles avec `LoginRequiredMixin`.
=== "Avec les groupes de permission" === "Avec les groupes de permission"
```python ```python
@ -83,15 +375,12 @@ Voici un exemple d'implémentation de ce système :
content = models.TextField(_("content")) content = models.TextField(_("content"))
# relation one-to-many # relation one-to-many
# Groupe possédant l'objet owner_group = models.ForeignKey( # (1)!
# Donne les droits d'édition des propriétés de l'objet
owner_group = models.ForeignKey(
Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID
) )
# relation many-to-many # relation many-to-many
# Tous les groupes qui seront ajouté dans ce champ auront les droits d'édition de l'objet edit_groups = models.ManyToManyField( # (2)!
edit_groups = models.ManyToManyField(
Group, Group,
related_name="editable_articles", related_name="editable_articles",
verbose_name=_("edit groups"), verbose_name=_("edit groups"),
@ -99,8 +388,7 @@ Voici un exemple d'implémentation de ce système :
) )
# relation many-to-many # relation many-to-many
# Tous les groupes qui seront ajouté dans ce champ auront les droits de vue de l'objet view_groups = models.ManyToManyField( # (3)!
view_groups = models.ManyToManyField(
Group, Group,
related_name="viewable_articles", related_name="viewable_articles",
verbose_name=_("view groups"), verbose_name=_("view groups"),
@ -108,18 +396,25 @@ Voici un exemple d'implémentation de ce système :
) )
``` ```
### Appliquer les permissions 1. Groupe possédant l'objet
Donne les droits d'édition des propriétés de l'objet.
Il ne peut y avoir qu'un seul groupe `owner` par objet.
2. Tous les groupes ayant droit d'édition sur l'objet.
Il peut y avoir autant de groupes d'édition que l'on veut par objet.
3. Tous les groupes ayant droit de voir l'objet.
Il peut y avoir autant de groupes de vue que l'on veut par objet.
#### Dans un template
#### Application dans les templates
Il existe trois fonctions de base sur lesquelles Il existe trois fonctions de base sur lesquelles
reposent les vérifications de permission. reposent les vérifications de permission.
Elles sont disponibles dans le contexte par défaut du Elles sont disponibles dans le contexte par défaut du
moteur de template et peuvent être utilisées à tout moment. moteur de template et peuvent être utilisées à tout moment.
- [can_edit_prop(obj, user)][core.views.can_edit_prop] : équivalent de `obj.is_owned_by(user)` - [can_edit_prop(obj, user)][core.auth.mixins.can_edit_prop] : équivalent de `obj.is_owned_by(user)`
- [can_edit(obj, user)][core.views.can_edit] : équivalent de `obj.can_be_edited_by(user)` - [can_edit(obj, user)][core.auth.mixins.can_edit] : équivalent de `obj.can_be_edited_by(user)`
- [can_view(obj, user)][core.views.can_view] : équivalent de `obj.can_be_viewed_by(user)` - [can_view(obj, user)][core.auth.mixins.can_view] : équivalent de `obj.can_be_viewed_by(user)`
Voici un exemple d'utilisation dans un template : Voici un exemple d'utilisation dans un template :
@ -130,7 +425,7 @@ Voici un exemple d'utilisation dans un template :
{% endif %} {% endif %}
``` ```
#### Dans une vue #### Application dans les vues
Généralement, les vérifications de droits dans les templates Généralement, les vérifications de droits dans les templates
se limitent aux urls à afficher puisqu'il se limitent aux urls à afficher puisqu'il
@ -138,7 +433,7 @@ ne faut normalement pas mettre de logique autre que d'affichage à l'intérieur
(en réalité, c'est un principe qu'on a beaucoup violé, mais promis on le fera plus). (en réalité, c'est un principe qu'on a beaucoup violé, mais promis on le fera plus).
C'est donc habituellement au niveau des vues que cela a lieu. C'est donc habituellement au niveau des vues que cela a lieu.
Notre système s'appuie sur un système de mixin Pour cela, nous avons rajouté des mixins
à hériter lors de la création d'une vue basée sur une classe. à hériter lors de la création d'une vue basée sur une classe.
Ces mixins ne sont compatibles qu'avec les classes récupérant Ces mixins ne sont compatibles qu'avec les classes récupérant
un objet ou une liste d'objet. un objet ou une liste d'objet.
@ -152,34 +447,60 @@ l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment : Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
```python ```python
from django.views.generic import CreateView, ListView from django.views.generic import CreateView, DetailView
from core.views import CanViewMixin, CanCreateMixin from core.auth.mixins import CanViewMixin, CanCreateMixin
from com.models import WeekmailArticle from com.models import WeekmailArticle
# Il est important de mettre le mixin avant la classe héritée de Django # Il est important de mettre le mixin avant la classe héritée de Django
# L'héritage multiple se fait de droite à gauche et les mixins ont besoin # L'héritage multiple se fait de droite à gauche et les mixins ont besoin
# d'une classe de base pour fonctionner correctement. # d'une classe de base pour fonctionner correctement.
class ArticlesListView(CanViewMixin, ListView): class ArticlesDetailView(CanViewMixin, DetailView):
model = WeekmailArticle model = WeekmailArticle
# Même chose pour une vue de création de l'objet Article # Même chose pour une vue de création de l'objet Article
class ArticlesCreateView(CanCreateMixin, CreateView): class ArticlesCreateView(CanCreateMixin, CreateView):
model = WeekmailArticle model = WeekmailArticle
``` ```
Les mixins suivants sont implémentés : Les mixins suivants sont implémentés :
- [CanCreateMixin][core.views.CanCreateMixin] : l'utilisateur peut-il créer l'objet ? - [CanCreateMixin][core.auth.mixins.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
- [CanEditPropMixin][core.views.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ? Ce mixin existe, mais est déprécié et ne doit plus être utilisé !
- [CanEditMixin][core.views.CanEditMixin] : L'utilisateur peut-il éditer l'objet ? - [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
- [CanViewMixin][core.views.CanViewMixin] : L'utilisateur peut-il voir l'objet ? - [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
- [UserIsRootMixin][core.views.UserIsRootMixin] : L'utilisateur a-t-il les droit root ? - [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
- [FormerSubscriberMixin][core.views.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ? - [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
- [UserIsLoggedMixin][core.views.UserIsLoggedMixin] : L'utilisateur est-il connecté ?
(à éviter ; préférez `LoginRequiredMixin`, fourni par Django) !!!danger "CanCreateMixin"
L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
étendu.
La façon dont ce mixin marche est qu'il valide le formulaire
de création et crée l'objet sans le persister en base de données, puis
vérifie les droits sur cet objet non-persisté.
Le danger de ce système vient de multiples raisons :
- Les vérifications se faisant sur un objet non persisté,
l'utilisation de mécanismes nécessitant une persistance préalable
peut mener à des comportements indésirés, voire à des erreurs.
- Les développeurs de django ayant tendance à restreindre progressivement
les actions qui peuvent être faites sur des objets non-persistés,
les mises-à-jour de django deviennent plus compliquées.
- La vérification des droits ne se fait que dans les requêtes POST,
à la toute fin de la requête.
Tout ce qui arrive avant n'est absolument pas protégé.
Toute opération (même les suppressions et les créations) qui ont
lieu avant la persistance de l'objet seront appliquées,
même sans permission.
- Si un développeur du site fait l'erreur de surcharger
la méthode `form_valid` (ce qui est plutôt courant,
lorsqu'on veut accomplir certaines actions
quand un formulaire est valide), on peut se retrouver
dans une situation où l'objet est persisté sans aucune protection.
!!!danger "Performance" !!!danger "Performance"
@ -197,6 +518,76 @@ Les mixins suivants sont implémentés :
Mais sur les `ListView`, on peut arriver à des temps Mais sur les `ListView`, on peut arriver à des temps
de réponse extrêmement élevés. de réponse extrêmement élevés.
### Filtrage des querysets
Récupérer tous les objets d'un queryset et vérifier pour chacun que
l'utilisateur a le droit de les voir peut-être excessivement
coûteux en ressources
(cf. l'encart ci-dessus).
Lorsqu'il est nécessaire de récupérer un certain nombre
d'objets depuis la base de données, il est donc préférable
de filtrer directement depuis le queryset.
Pour cela, certains modèles, tels que [Picture][sas.models.Picture]
peuvent être filtrés avec la méthode de queryset `viewable_by`.
Cette dernière s'utilise comme n'importe quelle autre méthode
de queryset :
```python
from sas.models import Picture
from core.models import User
user = User.objects.get(username="bibou")
pictures = Picture.objects.viewable_by(user)
```
Le résultat de la requête contiendra uniquement des éléments
que l'utilisateur sélectionné a effectivement le droit de voir.
Si vous désirez utiliser cette méthode sur un modèle
qui ne la possède pas, il est relativement facile de l'écrire :
```python
from typing import Self
from django.db import models
from core.models import User
class NewsQuerySet(models.QuerySet): # (1)!
def viewable_by(self, user: User) -> Self:
if user.has_perm("com.view_unmoderated_news"):
# si l'utilisateur peut tout voir, on retourne tout
return self
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur
# est l'auteur
return self.filter(
models.Q(is_moderated=True)
| models.Q(author=user)
)
class News(models.Model):
is_moderated = models.BooleanField(default=False)
author = models.ForeignKey(User, on_delete=models.PROTECT)
# ...
objects = NewsQuerySet.as_manager() # (2)!
class Meta:
permissions = [("view_unmoderated_news", "Can view non moderated news")]
```
1. On crée un `QuerySet` maison, dans lequel on définit la méthode `viewable_by`
2. Puis, on attache ce `QuerySet` à notre modèle
!!!note
Pour plus d'informations sur la création de `QuerySet` personnalisés, voir
[la documentation de django](https://docs.djangoproject.com/fr/stable/topics/db/managers/)
## API ## API
L'API utilise son propre système de permissions. L'API utilise son propre système de permissions.

View File

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django import forms from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@ -10,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
from core.views.widgets.select import ( from core.views.widgets.select import (
@ -300,7 +301,7 @@ class VoteFormView(CanCreateMixin, FormView):
# Create views # Create views
class CandidatureCreateView(CanCreateMixin, CreateView): class CandidatureCreateView(LoginRequiredMixin, CreateView):
"""View dedicated to a cundidature creation.""" """View dedicated to a cundidature creation."""
form_class = CandidateForm form_class = CandidateForm
@ -326,12 +327,13 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
"""Verify that the selected user is in candidate group.""" """Verify that the selected user is in candidate group."""
obj = form.instance obj = form.instance
obj.election = Election.objects.get(id=self.election.id) obj.election = self.election
obj.user = obj.user if hasattr(obj, "user") else self.request.user if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and ( if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit obj.user == self.request.user or self.can_edit
): ):
return super(CreateView, self).form_valid(form) return super().form_valid(form)
raise PermissionDenied raise PermissionDenied
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -343,22 +345,14 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class ElectionCreateView(CanCreateMixin, CreateView): class ElectionCreateView(PermissionRequiredMixin, CreateView):
model = Election model = Election
form_class = ElectionForm form_class = ElectionForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "election.add_election"
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
"""Allow every user that had passed the dispatch to create an election."""
return super(CreateView, self).form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id}) return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(CanCreateMixin, CreateView): class RoleCreateView(CanCreateMixin, CreateView):

View File

@ -43,7 +43,7 @@ from haystack.query import RelatedSearchQuerySet
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from club.widgets.select import AutoCompleteSelectClub from club.widgets.select import AutoCompleteSelectClub
from core.views import ( from core.auth.mixins import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,

View File

@ -27,12 +27,9 @@ from django.http import Http404, JsonResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, View from django.views.generic import DetailView, View
from core.auth.mixins import CanViewMixin, FormerSubscriberMixin
from core.models import User from core.models import User
from core.views import ( from core.views import UserTabsMixin
CanViewMixin,
FormerSubscriberMixin,
UserTabsMixin,
)
from galaxy.models import Galaxy, GalaxyLane from galaxy.models import Galaxy, GalaxyLane

View File

@ -19,6 +19,7 @@ from datetime import timezone as tz
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction from django.db import transaction
from django.template import defaultfilters from django.template import defaultfilters
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -28,8 +29,8 @@ from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import BaseFormView, CreateView, DeleteView, UpdateView from django.views.generic.edit import BaseFormView, CreateView, DeleteView, UpdateView
from club.models import Club from club.models import Club
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Page, User from core.models import Page, User
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from counter.forms import GetUserForm from counter.forms import GetUserForm
from counter.models import Counter, Customer, Selling from counter.models import Counter, Customer, Selling
from launderette.models import Launderette, Machine, Slot, Token from launderette.models import Launderette, Machine, Slot, Token
@ -186,12 +187,13 @@ class LaunderetteEditView(CanEditPropMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
class LaunderetteCreateView(CanCreateMixin, CreateView): class LaunderetteCreateView(PermissionRequiredMixin, CreateView):
"""Create a new launderette.""" """Create a new launderette."""
model = Launderette model = Launderette
fields = ["name"] fields = ["name"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "launderette.add_launderette"
def form_valid(self, form): def form_valid(self, form):
club = Club.objects.filter( club = Club.objects.filter(
@ -492,12 +494,13 @@ class MachineDeleteView(CanEditPropMixin, DeleteView):
success_url = reverse_lazy("launderette:launderette_list") success_url = reverse_lazy("launderette:launderette_list")
class MachineCreateView(CanCreateMixin, CreateView): class MachineCreateView(PermissionRequiredMixin, CreateView):
"""Create a new machine.""" """Create a new machine."""
model = Machine model = Machine
fields = ["name", "launderette", "type"] fields = ["name", "launderette", "type"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "launderette.add_machine"
def get_initial(self): def get_initial(self):
ret = super().get_initial() ret = super().get_initial()

View File

@ -32,8 +32,9 @@ from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from core.auth.mixins import FormerSubscriberMixin
from core.models import User from core.models import User
from core.views import FormerSubscriberMixin, search_user from core.views import search_user
from core.views.forms import SelectDate from core.views.forms import SelectDate
# Enum to select search type # Enum to select search type

View File

@ -98,7 +98,7 @@ nav:
- Champs de modèle: reference/core/model_fields.md - Champs de modèle: reference/core/model_fields.md
- reference/core/views.md - reference/core/views.md
- reference/core/schemas.md - reference/core/schemas.md
- reference/core/api_permissions.md - reference/core/auth.md
- counter: - counter:
- reference/counter/models.md - reference/counter/models.md
- reference/counter/views.md - reference/counter/views.md

View File

@ -7,7 +7,7 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound from ninja_extra.exceptions import NotFound
from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema
from core.api_permissions import IsInGroup, IsRoot, IsSubscriber from core.auth.api_permissions import IsInGroup, IsRoot, IsSubscriber
from pedagogy.models import UV from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.utbm_api import find_uv from pedagogy.utbm_api import find_uv

View File

@ -26,6 +26,7 @@ from django.conf import settings
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pytest_django.asserts import assertRedirects
from core.models import Notification, User from core.models import Notification, User
from pedagogy.models import UV, UVComment, UVCommentReport from pedagogy.models import UV, UVComment, UVCommentReport
@ -106,7 +107,7 @@ class TestUVCreation(TestCase):
def test_create_uv_unauthorized_fail(self): def test_create_uv_unauthorized_fail(self):
# Test with anonymous user # Test with anonymous user
response = self.client.post(self.create_uv_url, create_uv_template(0)) response = self.client.post(self.create_uv_url, create_uv_template(0))
assert response.status_code == 403 assertRedirects(response, reverse("core:login") + f"?next={self.create_uv_url}")
# Test with subscribed user # Test with subscribed user
self.client.force_login(self.sli) self.client.force_login(self.sli)
@ -815,11 +816,11 @@ class TestUVCommentReportCreate(TestCase):
self.create_report_test("guy", success=False) self.create_report_test("guy", success=False)
def test_create_report_anonymous_fail(self): def test_create_report_anonymous_fail(self):
url = reverse("pedagogy:comment_report", kwargs={"comment_id": self.comment.id})
response = self.client.post( response = self.client.post(
reverse("pedagogy:comment_report", kwargs={"comment_id": self.comment.id}), url, {"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"}
{"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"},
) )
assert response.status_code == 403 assertRedirects(response, reverse("core:login") + f"?next={url}")
assert not UVCommentReport.objects.all().exists() assert not UVCommentReport.objects.all().exists()
def test_notifications(self): def test_notifications(self):

View File

@ -22,7 +22,7 @@
# #
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -35,14 +35,9 @@ from django.views.generic import (
UpdateView, UpdateView,
) )
from core.auth.mixins import CanEditPropMixin, CanViewMixin, FormerSubscriberMixin
from core.models import Notification, User from core.models import Notification, User
from core.views import ( from core.views import DetailFormView
CanCreateMixin,
CanEditPropMixin,
CanViewMixin,
DetailFormView,
FormerSubscriberMixin,
)
from pedagogy.forms import ( from pedagogy.forms import (
UVCommentForm, UVCommentForm,
UVCommentModerationForm, UVCommentModerationForm,
@ -51,8 +46,6 @@ from pedagogy.forms import (
) )
from pedagogy.models import UV, UVComment, UVCommentReport from pedagogy.models import UV, UVComment, UVCommentReport
# Acutal views
class UVDetailFormView(CanViewMixin, DetailFormView): class UVDetailFormView(CanViewMixin, DetailFormView):
"""Display every comment of an UV and detailed infos about it. """Display every comment of an UV and detailed infos about it.
@ -138,12 +131,13 @@ class UVGuideView(LoginRequiredMixin, FormerSubscriberMixin, TemplateView):
} }
class UVCommentReportCreateView(CanCreateMixin, CreateView): class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inapropriate comment.""" """Create a new report for an inapropriate comment."""
model = UVCommentReport model = UVCommentReport
form_class = UVCommentReportForm form_class = UVCommentReportForm
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
permission_required = "pedagogy.add_uvcommentreport"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.uv_comment = get_object_or_404(UVComment, pk=kwargs["comment_id"]) self.uv_comment = get_object_or_404(UVComment, pk=kwargs["comment_id"])
@ -204,12 +198,13 @@ class UVModerationFormView(FormView):
return reverse_lazy("pedagogy:moderation") return reverse_lazy("pedagogy:moderation")
class UVCreateView(CanCreateMixin, CreateView): class UVCreateView(PermissionRequiredMixin, CreateView):
"""Add a new UV (Privileged).""" """Add a new UV (Privileged)."""
model = UV model = UV
form_class = UVForm form_class = UVForm
template_name = "pedagogy/uv_edit.jinja" template_name = "pedagogy/uv_edit.jinja"
permission_required = "pedagogy.add_uv"
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()

View File

@ -12,7 +12,7 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from core.models import Notification, User from core.models import Notification, User
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (

View File

@ -23,8 +23,8 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView from django.views.generic import DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, send_file from core.views.files import FileView, send_file
from sas.forms import ( from sas.forms import (
AlbumEditForm, AlbumEditForm,

View File

@ -155,13 +155,12 @@ TEMPLATES = [
"add_attr": "core.templatetags.renderer.add_attr", "add_attr": "core.templatetags.renderer.add_attr",
}, },
"globals": { "globals": {
"can_edit_prop": "core.views.can_edit_prop", "can_edit_prop": "core.auth.mixins.can_edit_prop",
"can_edit": "core.views.can_edit", "can_edit": "core.auth.mixins.can_edit",
"can_view": "core.views.can_view", "can_view": "core.auth.mixins.can_view",
"settings": "sith.settings", "settings": "sith.settings",
"Launderette": "launderette.models.Launderette", "Launderette": "launderette.models.Launderette",
"Counter": "counter.models.Counter", "Counter": "counter.models.Counter",
"ProductType": "counter.models.ProductType",
"timezone": "django.utils.timezone", "timezone": "django.utils.timezone",
"get_sith": "com.views.sith", "get_sith": "com.views.sith",
"get_language": "django.utils.translation.get_language", "get_language": "django.utils.translation.get_language",
@ -292,9 +291,9 @@ STORAGES = {
# Auth configuration # Auth configuration
AUTH_USER_MODEL = "core.User" AUTH_USER_MODEL = "core.User"
AUTH_ANONYMOUS_MODEL = "core.models.AnonymousUser" AUTH_ANONYMOUS_MODEL = "core.models.AnonymousUser"
AUTHENTICATION_BACKENDS = ["core.auth_backends.SithModelBackend"] AUTHENTICATION_BACKENDS = ["core.auth.backends.SithModelBackend"]
LOGIN_URL = "/login" LOGIN_URL = "/login/"
LOGOUT_URL = "/logout" LOGOUT_URL = "/logout/"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
DEFAULT_FROM_EMAIL = "bibou@git.an" DEFAULT_FROM_EMAIL = "bibou@git.an"
SITH_COM_EMAIL = "bibou_com@git.an" SITH_COM_EMAIL = "bibou_com@git.an"

View File

@ -38,16 +38,15 @@ from django.views.generic import DetailView, RedirectView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club from club.models import Club
from core.models import User from core.auth.mixins import (
from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
) )
from core.models import User
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser from core.views.widgets.select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser