mirror of
https://gitlab.crans.org/bde/nk20
synced 2024-11-26 18:37:12 +00:00
Implements permission masks
This commit is contained in:
parent
d083894e9b
commit
95315cdbe2
@ -1,55 +0,0 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
|
|
||||||
from threading import local
|
|
||||||
|
|
||||||
|
|
||||||
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
|
|
||||||
IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip')
|
|
||||||
|
|
||||||
_thread_locals = local()
|
|
||||||
|
|
||||||
|
|
||||||
def _set_current_user_and_ip(user=None, ip=None):
|
|
||||||
setattr(_thread_locals, USER_ATTR_NAME, user)
|
|
||||||
setattr(_thread_locals, IP_ATTR_NAME, ip)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user():
|
|
||||||
return getattr(_thread_locals, USER_ATTR_NAME, None)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_ip():
|
|
||||||
return getattr(_thread_locals, IP_ATTR_NAME, None)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_authenticated_user():
|
|
||||||
current_user = get_current_user()
|
|
||||||
if isinstance(current_user, AnonymousUser):
|
|
||||||
return None
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
class LogsMiddleware(object):
|
|
||||||
"""
|
|
||||||
This middleware get the current user with his or her IP address on each request.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, get_response):
|
|
||||||
self.get_response = get_response
|
|
||||||
|
|
||||||
def __call__(self, request):
|
|
||||||
user = request.user
|
|
||||||
if 'HTTP_X_FORWARDED_FOR' in request.META:
|
|
||||||
ip = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
||||||
else:
|
|
||||||
ip = request.META.get('REMOTE_ADDR')
|
|
||||||
|
|
||||||
_set_current_user_and_ip(user, ip)
|
|
||||||
response = self.get_response(request)
|
|
||||||
_set_current_user_and_ip(None, None)
|
|
||||||
|
|
||||||
return response
|
|
@ -9,7 +9,7 @@ import getpass
|
|||||||
|
|
||||||
from note.models import NoteUser, Alias
|
from note.models import NoteUser, Alias
|
||||||
|
|
||||||
from .middlewares import get_current_authenticated_user, get_current_ip
|
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip
|
||||||
from .models import Changelog
|
from .models import Changelog
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,10 +3,10 @@
|
|||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from django.db.models import Q, F
|
from django.db.models import Q, F
|
||||||
|
|
||||||
from note.models import Note, NoteUser, NoteClub, NoteSpecial
|
from note.models import Note, NoteUser, NoteClub, NoteSpecial
|
||||||
|
from note_kfet.middlewares import get_current_session
|
||||||
from .models import Membership, RolePermissions, Club
|
from .models import Membership, RolePermissions, Club
|
||||||
from django.contrib.auth.backends import ModelBackend
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
|
||||||
@ -37,6 +37,7 @@ class PermissionBackend(ModelBackend):
|
|||||||
F=F,
|
F=F,
|
||||||
Q=Q
|
Q=Q
|
||||||
)
|
)
|
||||||
|
if permission.mask.rank <= get_current_session().get("permission_mask", 0):
|
||||||
yield permission
|
yield permission
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -50,7 +51,7 @@ class PermissionBackend(ModelBackend):
|
|||||||
:return: A query that corresponds to the filter to give to a queryset
|
:return: A query that corresponds to the filter to give to a queryset
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if user.is_superuser:
|
if user.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
|
||||||
# Superusers have all rights
|
# Superusers have all rights
|
||||||
return Q()
|
return Q()
|
||||||
|
|
||||||
@ -68,7 +69,7 @@ class PermissionBackend(ModelBackend):
|
|||||||
return query
|
return query
|
||||||
|
|
||||||
def has_perm(self, user_obj, perm, obj=None):
|
def has_perm(self, user_obj, perm, obj=None):
|
||||||
if user_obj.is_superuser:
|
if user_obj.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if obj is None:
|
if obj is None:
|
||||||
|
@ -6,12 +6,21 @@ from crispy_forms.helper import FormHelper
|
|||||||
from crispy_forms.layout import Layout
|
from crispy_forms.layout import Layout
|
||||||
from dal import autocomplete
|
from dal import autocomplete
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.forms import UserCreationForm
|
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from permission.models import PermissionMask
|
||||||
from .models import Profile, Club, Membership
|
from .models import Profile, Club, Membership
|
||||||
|
|
||||||
|
|
||||||
|
class CustomAuthenticationForm(AuthenticationForm):
|
||||||
|
permission_mask = forms.ModelChoiceField(
|
||||||
|
label="Masque de permissions",
|
||||||
|
queryset=PermissionMask.objects.order_by("rank"),
|
||||||
|
empty_label=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SignUpForm(UserCreationForm):
|
class SignUpForm(UserCreationForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -9,6 +9,7 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.auth.views import LoginView
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
@ -26,11 +27,20 @@ from note.tables import HistoryTable, AliasTable
|
|||||||
from .backends import PermissionBackend
|
from .backends import PermissionBackend
|
||||||
|
|
||||||
from .filters import UserFilter, UserFilterFormHelper
|
from .filters import UserFilter, UserFilterFormHelper
|
||||||
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper
|
from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, MemberFormSet, FormSetHelper, \
|
||||||
|
CustomAuthenticationForm
|
||||||
from .models import Club, Membership
|
from .models import Club, Membership
|
||||||
from .tables import ClubTable, UserTable
|
from .tables import ClubTable, UserTable
|
||||||
|
|
||||||
|
|
||||||
|
class CustomLoginView(LoginView):
|
||||||
|
form_class = CustomAuthenticationForm
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
class UserCreateView(CreateView):
|
class UserCreateView(CreateView):
|
||||||
"""
|
"""
|
||||||
Une vue pour inscrire un utilisateur et lui créer un profile
|
Une vue pour inscrire un utilisateur et lui créer un profile
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_polymorphic.serializers import PolymorphicSerializer
|
from rest_polymorphic.serializers import PolymorphicSerializer
|
||||||
|
|
||||||
from logs.middlewares import get_current_authenticated_user
|
from note_kfet.middlewares import get_current_authenticated_user
|
||||||
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
|
from ..models.notes import Note, NoteClub, NoteSpecial, NoteUser, Alias
|
||||||
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
|
from ..models.transactions import TransactionTemplate, Transaction, MembershipTransaction, TemplateCategory, \
|
||||||
TemplateTransaction, SpecialTransaction
|
TemplateTransaction, SpecialTransaction
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"model": "note.note",
|
"model": "note.note",
|
||||||
"pk": 1,
|
"pk": 1,
|
||||||
"fields": {
|
"fields": {
|
||||||
"polymorphic_ctype": 40,
|
"polymorphic_ctype": 41,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"display_image": "",
|
"display_image": "",
|
||||||
@ -14,7 +14,7 @@
|
|||||||
"model": "note.note",
|
"model": "note.note",
|
||||||
"pk": 2,
|
"pk": 2,
|
||||||
"fields": {
|
"fields": {
|
||||||
"polymorphic_ctype": 40,
|
"polymorphic_ctype": 41,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"display_image": "",
|
"display_image": "",
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"model": "note.note",
|
"model": "note.note",
|
||||||
"pk": 3,
|
"pk": 3,
|
||||||
"fields": {
|
"fields": {
|
||||||
"polymorphic_ctype": 40,
|
"polymorphic_ctype": 41,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"display_image": "",
|
"display_image": "",
|
||||||
@ -36,7 +36,7 @@
|
|||||||
"model": "note.note",
|
"model": "note.note",
|
||||||
"pk": 4,
|
"pk": 4,
|
||||||
"fields": {
|
"fields": {
|
||||||
"polymorphic_ctype": 40,
|
"polymorphic_ctype": 41,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"display_image": "",
|
"display_image": "",
|
||||||
@ -47,7 +47,7 @@
|
|||||||
"model": "note.note",
|
"model": "note.note",
|
||||||
"pk": 5,
|
"pk": 5,
|
||||||
"fields": {
|
"fields": {
|
||||||
"polymorphic_ctype": 39,
|
"polymorphic_ctype": 40,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"display_image": "",
|
"display_image": "",
|
||||||
@ -58,7 +58,7 @@
|
|||||||
"model": "note.note",
|
"model": "note.note",
|
||||||
"pk": 6,
|
"pk": 6,
|
||||||
"fields": {
|
"fields": {
|
||||||
"polymorphic_ctype": 39,
|
"polymorphic_ctype": 40,
|
||||||
"balance": 0,
|
"balance": 0,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
"display_image": "",
|
"display_image": "",
|
||||||
|
@ -3,7 +3,15 @@
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Permission
|
from .models import Permission, PermissionMask
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PermissionMask)
|
||||||
|
class PermissionMaskAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin customisation for Permission
|
||||||
|
"""
|
||||||
|
list_display = ('rank', 'description')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Permission)
|
@admin.register(Permission)
|
||||||
|
@ -50,6 +50,20 @@ class InstancedPermission:
|
|||||||
return self.__repr__()
|
return self.__repr__()
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMask(models.Model):
|
||||||
|
rank = models.PositiveSmallIntegerField(
|
||||||
|
verbose_name=_('rank'),
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('description'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.description
|
||||||
|
|
||||||
|
|
||||||
class Permission(models.Model):
|
class Permission(models.Model):
|
||||||
|
|
||||||
PERMISSION_TYPES = [
|
PERMISSION_TYPES = [
|
||||||
@ -85,6 +99,11 @@ class Permission(models.Model):
|
|||||||
|
|
||||||
type = models.CharField(max_length=15, choices=PERMISSION_TYPES)
|
type = models.CharField(max_length=15, choices=PERMISSION_TYPES)
|
||||||
|
|
||||||
|
mask = models.ForeignKey(
|
||||||
|
PermissionMask,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
|
|
||||||
field = models.CharField(max_length=255, blank=True)
|
field = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
description = models.CharField(max_length=255, blank=True)
|
description = models.CharField(max_length=255, blank=True)
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from logs.middlewares import get_current_authenticated_user
|
from note_kfet.middlewares import get_current_authenticated_user
|
||||||
|
|
||||||
|
|
||||||
EXCLUDED = [
|
EXCLUDED = [
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.template.defaultfilters import stringfilter
|
from django.template.defaultfilters import stringfilter
|
||||||
|
|
||||||
from logs.middlewares import get_current_authenticated_user
|
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
|
||||||
from django import template
|
from django import template
|
||||||
|
|
||||||
from member.backends import PermissionBackend
|
from member.backends import PermissionBackend
|
||||||
@ -19,7 +19,7 @@ def not_empty_model_list(model_name):
|
|||||||
user = get_current_authenticated_user()
|
user = get_current_authenticated_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
return False
|
return False
|
||||||
elif user.is_superuser:
|
elif user.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
|
||||||
return True
|
return True
|
||||||
spl = model_name.split(".")
|
spl = model_name.split(".")
|
||||||
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
|
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
|
||||||
@ -32,7 +32,7 @@ def not_empty_model_change_list(model_name):
|
|||||||
user = get_current_authenticated_user()
|
user = get_current_authenticated_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
return False
|
return False
|
||||||
elif user.is_superuser:
|
elif user.is_superuser and get_current_session().get("permission_mask", 0) >= 42:
|
||||||
return True
|
return True
|
||||||
spl = model_name.split(".")
|
spl = model_name.split(".")
|
||||||
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
|
ct = ContentType.objects.get(app_label=spl[0], model=spl[1])
|
||||||
|
@ -7,7 +7,7 @@ if [ -z ${NOTE_URL+x} ]; then
|
|||||||
else
|
else
|
||||||
sed -i -e "s/example.com/$DOMAIN/g" /code/apps/member/fixtures/initial.json
|
sed -i -e "s/example.com/$DOMAIN/g" /code/apps/member/fixtures/initial.json
|
||||||
sed -i -e "s/localhost/$NOTE_URL/g" /code/note_kfet/fixtures/initial.json
|
sed -i -e "s/localhost/$NOTE_URL/g" /code/note_kfet/fixtures/initial.json
|
||||||
sed -i -e "s/\.\*/https?:\/\/$NOTE_URL\/.*/g" /code/note_kfet/fixtures/cas.json
|
sed -i -e "s/\"\.\*\"/\"https?:\/\/$NOTE_URL\/.*\"/g" /code/note_kfet/fixtures/cas.json
|
||||||
sed -i -e "s/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json
|
sed -i -e "s/REPLACEME/La Note Kfet \\\\ud83c\\\\udf7b/g" /code/note_kfet/fixtures/cas.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -1,6 +1,66 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser, User
|
||||||
|
|
||||||
|
from threading import local
|
||||||
|
|
||||||
|
from django.contrib.sessions.backends.db import SessionStore
|
||||||
|
|
||||||
|
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
|
||||||
|
SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session')
|
||||||
|
IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip')
|
||||||
|
|
||||||
|
_thread_locals = local()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_current_user_and_ip(user=None, session=None, ip=None):
|
||||||
|
setattr(_thread_locals, USER_ATTR_NAME, user)
|
||||||
|
setattr(_thread_locals, SESSION_ATTR_NAME, session)
|
||||||
|
setattr(_thread_locals, IP_ATTR_NAME, ip)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> User:
|
||||||
|
return getattr(_thread_locals, USER_ATTR_NAME, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_session() -> SessionStore:
|
||||||
|
return getattr(_thread_locals, SESSION_ATTR_NAME, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_ip() -> str:
|
||||||
|
return getattr(_thread_locals, IP_ATTR_NAME, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_authenticated_user():
|
||||||
|
current_user = get_current_user()
|
||||||
|
if isinstance(current_user, AnonymousUser):
|
||||||
|
return None
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
class SessionMiddleware(object):
|
||||||
|
"""
|
||||||
|
This middleware get the current user with his or her IP address on each request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
user = request.user
|
||||||
|
if 'HTTP_X_FORWARDED_FOR' in request.META:
|
||||||
|
ip = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
else:
|
||||||
|
ip = request.META.get('REMOTE_ADDR')
|
||||||
|
|
||||||
|
_set_current_user_and_ip(user, request.session, ip)
|
||||||
|
response = self.get_response(request)
|
||||||
|
_set_current_user_and_ip(None, None, None)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class TurbolinksMiddleware(object):
|
class TurbolinksMiddleware(object):
|
||||||
"""
|
"""
|
||||||
|
@ -74,7 +74,7 @@ if "cas" in INSTALLED_APPS:
|
|||||||
|
|
||||||
|
|
||||||
if "logs" in INSTALLED_APPS:
|
if "logs" in INSTALLED_APPS:
|
||||||
MIDDLEWARE += ('logs.middlewares.LogsMiddleware',)
|
MIDDLEWARE += ('note_kfet.middlewares.SessionMiddleware',)
|
||||||
|
|
||||||
if "debug_toolbar" in INSTALLED_APPS:
|
if "debug_toolbar" in INSTALLED_APPS:
|
||||||
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware")
|
MIDDLEWARE.insert(1, "debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||||
|
@ -7,6 +7,8 @@ from django.contrib import admin
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
|
from member.views import CustomLoginView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Dev so redirect to something random
|
# Dev so redirect to something random
|
||||||
path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'),
|
path('', RedirectView.as_view(pattern_name='note:transfer'), name='index'),
|
||||||
@ -16,10 +18,11 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Include Django Contrib and Core routers
|
# Include Django Contrib and Core routers
|
||||||
path('i18n/', include('django.conf.urls.i18n')),
|
path('i18n/', include('django.conf.urls.i18n')),
|
||||||
path('accounts/', include('member.urls')),
|
|
||||||
path('accounts/', include('django.contrib.auth.urls')),
|
|
||||||
path('admin/doc/', include('django.contrib.admindocs.urls')),
|
path('admin/doc/', include('django.contrib.admindocs.urls')),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
path('accounts/', include('member.urls')),
|
||||||
|
path('accounts/login/', CustomLoginView.as_view()),
|
||||||
|
path('accounts/', include('django.contrib.auth.urls')),
|
||||||
path('api/', include('api.urls')),
|
path('api/', include('api.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user