mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-15 20:47:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c50c6f5517 |
No files matched your search
+1
-1
@@ -43,7 +43,7 @@ dependencies = [
|
|||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock>=3.32,<4.1",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.3",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from guardian.core import ObjectPermissionChecker
|
|||||||
from guardian.models import GroupObjectPermission
|
from guardian.models import GroupObjectPermission
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from guardian.shortcuts import remove_perm
|
from guardian.shortcuts import remove_perm
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
@@ -648,6 +649,41 @@ def annotate_document_count_for_related_queryset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_objects_for_user_owner_aware(
|
||||||
|
user: User | None,
|
||||||
|
perms: str | list[str],
|
||||||
|
Model: Any,
|
||||||
|
*,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
) -> QuerySet[Any]:
|
||||||
|
"""
|
||||||
|
Returns objects the user owns, are unowned, or has explicit perms.
|
||||||
|
When include_deleted is True, soft-deleted items are also included.
|
||||||
|
|
||||||
|
Legacy slow path (guardian-backed, O(n) style permission resolution).
|
||||||
|
Most queryset-filtering call sites have migrated onto
|
||||||
|
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
|
||||||
|
is kept because production callers still remain. Several callers remain
|
||||||
|
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
|
||||||
|
grep for this function name before removing it.
|
||||||
|
"""
|
||||||
|
manager = (
|
||||||
|
Model.global_objects
|
||||||
|
if include_deleted and hasattr(Model, "global_objects")
|
||||||
|
else Model.objects
|
||||||
|
)
|
||||||
|
|
||||||
|
objects_owned = manager.filter(owner=user)
|
||||||
|
objects_unowned = manager.filter(owner__isnull=True)
|
||||||
|
objects_with_perms = get_objects_for_user(
|
||||||
|
user=user,
|
||||||
|
perms=perms,
|
||||||
|
klass=manager.all(),
|
||||||
|
accept_global_perms=False,
|
||||||
|
)
|
||||||
|
return objects_owned | objects_unowned | objects_with_perms
|
||||||
|
|
||||||
|
|
||||||
def has_perms_owner_aware(user, perms, obj):
|
def has_perms_owner_aware(user, perms, obj):
|
||||||
"""
|
"""
|
||||||
Legacy slow path (guardian-backed) single-object permission check.
|
Legacy slow path (guardian-backed) single-object permission check.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ from documents.models import Workflow
|
|||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
@@ -81,11 +81,15 @@ DRF_DATETIME_FIELD = serializers.DateTimeField()
|
|||||||
|
|
||||||
|
|
||||||
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
||||||
inbox_tags = restrict_queryset_to_visible(
|
if document.owner is not None:
|
||||||
Tag.objects.filter(is_inbox_tag=True),
|
tags = get_objects_for_user_owner_aware(
|
||||||
document.owner,
|
document.owner,
|
||||||
"view_tag",
|
"documents.view_tag",
|
||||||
|
Tag,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
tags = Tag.objects.all()
|
||||||
|
inbox_tags = tags.filter(is_inbox_tag=True)
|
||||||
document.add_nested_tags(inbox_tags)
|
document.add_nested_tags(inbox_tags)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ from documents.tests.utils import GetConsumerMixin
|
|||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import TagFactory
|
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.fakes.progress import FakeProgressManager
|
from paperless_testing.fakes.progress import FakeProgressManager
|
||||||
from paperless_testing.permissions import grant_object
|
|
||||||
|
|
||||||
|
|
||||||
class _BaseNewStyleParser:
|
class _BaseNewStyleParser:
|
||||||
@@ -516,65 +514,6 @@ class TestConsumer(
|
|||||||
self.assertTrue(group_checker.has_perm("view_document", document))
|
self.assertTrue(group_checker.has_perm("view_document", document))
|
||||||
self._assert_first_last_send_progress()
|
self._assert_first_last_send_progress()
|
||||||
|
|
||||||
def testInboxTagsScopedToOwner(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Inbox tags that are owned by the document owner, unowned, owned by
|
|
||||||
another user but shared with the document owner, and owned by
|
|
||||||
another user and not shared
|
|
||||||
- A non-inbox tag owned by the document owner
|
|
||||||
WHEN:
|
|
||||||
- A document is consumed with that owner
|
|
||||||
THEN:
|
|
||||||
- The document gets the owned, unowned and shared inbox tags only
|
|
||||||
"""
|
|
||||||
owner = UserFactory()
|
|
||||||
other = UserFactory()
|
|
||||||
owned = TagFactory(is_inbox_tag=True, owner=owner)
|
|
||||||
unowned = TagFactory(is_inbox_tag=True, owner=None)
|
|
||||||
shared = TagFactory(is_inbox_tag=True, owner=other)
|
|
||||||
grant_object(owner, shared, "view_tag")
|
|
||||||
TagFactory(is_inbox_tag=True, owner=other)
|
|
||||||
TagFactory(is_inbox_tag=False, owner=owner)
|
|
||||||
|
|
||||||
with self.get_consumer(
|
|
||||||
self.get_test_file(),
|
|
||||||
DocumentMetadataOverrides(owner_id=owner.pk),
|
|
||||||
) as consumer:
|
|
||||||
consumer.run()
|
|
||||||
|
|
||||||
document = Document.objects.first()
|
|
||||||
assert document is not None
|
|
||||||
|
|
||||||
self.assertCountEqual(document.tags.all(), [owned, unowned, shared])
|
|
||||||
self._assert_first_last_send_progress()
|
|
||||||
|
|
||||||
def testInboxTagsUnownedDocument(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Inbox tags owned by two different users, an unowned inbox tag and
|
|
||||||
a non-inbox tag
|
|
||||||
WHEN:
|
|
||||||
- A document is consumed with no owner
|
|
||||||
THEN:
|
|
||||||
- The document gets every inbox tag
|
|
||||||
"""
|
|
||||||
inbox_tags = [
|
|
||||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
|
||||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
|
||||||
TagFactory(is_inbox_tag=True, owner=None),
|
|
||||||
]
|
|
||||||
TagFactory(is_inbox_tag=False)
|
|
||||||
|
|
||||||
with self.get_consumer(self.get_test_file()) as consumer:
|
|
||||||
consumer.run()
|
|
||||||
|
|
||||||
document = Document.objects.first()
|
|
||||||
assert document is not None
|
|
||||||
|
|
||||||
self.assertCountEqual(document.tags.all(), inbox_tags)
|
|
||||||
self._assert_first_last_send_progress()
|
|
||||||
|
|
||||||
def testNotAFile(self) -> None:
|
def testNotAFile(self) -> None:
|
||||||
with self.assertRaisesMessage(ConsumerError, "File not found"):
|
with self.assertRaisesMessage(ConsumerError, "File not found"):
|
||||||
with self.get_consumer(Path("non-existing-file")) as consumer:
|
with self.get_consumer(Path("non-existing-file")) as consumer:
|
||||||
|
|||||||
+31
-33
@@ -175,12 +175,12 @@ from documents.permissions import ViewDocumentsPermissions
|
|||||||
from documents.permissions import annotate_document_count_by_ids
|
from documents.permissions import annotate_document_count_by_ids
|
||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
from documents.permissions import annotate_document_count_for_related_queryset
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.permissions import has_global_statistics_permission
|
from documents.permissions import has_global_statistics_permission
|
||||||
from documents.permissions import has_perms_owner_aware
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.permissions import has_system_status_permission
|
from documents.permissions import has_system_status_permission
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.permissions import user_is_unrestricted
|
from documents.permissions import user_is_unrestricted
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
@@ -3836,51 +3836,49 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
if doc_id in docs_by_id
|
if doc_id in docs_by_id
|
||||||
][:OBJECT_LIMIT]
|
][:OBJECT_LIMIT]
|
||||||
saved_views = (
|
saved_views = (
|
||||||
restrict_queryset_to_visible(
|
get_objects_for_user_owner_aware(
|
||||||
SavedView.objects.filter(name__icontains=query),
|
|
||||||
request.user,
|
request.user,
|
||||||
"view_savedview",
|
"view_savedview",
|
||||||
)
|
SavedView,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("documents.view_savedview")
|
if request.user.has_perm("documents.view_savedview")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
saved_views = saved_views[:OBJECT_LIMIT]
|
saved_views = saved_views[:OBJECT_LIMIT]
|
||||||
tags = (
|
tags = (
|
||||||
restrict_queryset_to_visible(
|
get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
|
||||||
Tag.objects.filter(name__icontains=query),
|
name__icontains=query,
|
||||||
request.user,
|
|
||||||
"view_tag",
|
|
||||||
)
|
)
|
||||||
if request.user.has_perm("documents.view_tag")
|
if request.user.has_perm("documents.view_tag")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
tags = tags[:OBJECT_LIMIT]
|
tags = tags[:OBJECT_LIMIT]
|
||||||
correspondents = (
|
correspondents = (
|
||||||
restrict_queryset_to_visible(
|
get_objects_for_user_owner_aware(
|
||||||
Correspondent.objects.filter(name__icontains=query),
|
|
||||||
request.user,
|
request.user,
|
||||||
"view_correspondent",
|
"view_correspondent",
|
||||||
)
|
Correspondent,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("documents.view_correspondent")
|
if request.user.has_perm("documents.view_correspondent")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
correspondents = correspondents[:OBJECT_LIMIT]
|
correspondents = correspondents[:OBJECT_LIMIT]
|
||||||
document_types = (
|
document_types = (
|
||||||
restrict_queryset_to_visible(
|
get_objects_for_user_owner_aware(
|
||||||
DocumentType.objects.filter(name__icontains=query),
|
|
||||||
request.user,
|
request.user,
|
||||||
"view_documenttype",
|
"view_documenttype",
|
||||||
)
|
DocumentType,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("documents.view_documenttype")
|
if request.user.has_perm("documents.view_documenttype")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
document_types = document_types[:OBJECT_LIMIT]
|
document_types = document_types[:OBJECT_LIMIT]
|
||||||
storage_paths = (
|
storage_paths = (
|
||||||
restrict_queryset_to_visible(
|
get_objects_for_user_owner_aware(
|
||||||
StoragePath.objects.filter(name__icontains=query),
|
|
||||||
request.user,
|
request.user,
|
||||||
"view_storagepath",
|
"view_storagepath",
|
||||||
)
|
StoragePath,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("documents.view_storagepath")
|
if request.user.has_perm("documents.view_storagepath")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
@@ -3898,21 +3896,21 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
)
|
)
|
||||||
groups = groups[:OBJECT_LIMIT]
|
groups = groups[:OBJECT_LIMIT]
|
||||||
mail_rules = (
|
mail_rules = (
|
||||||
restrict_queryset_to_visible(
|
get_objects_for_user_owner_aware(
|
||||||
MailRule.objects.filter(name__icontains=query),
|
|
||||||
request.user,
|
request.user,
|
||||||
"view_mailrule",
|
"view_mailrule",
|
||||||
)
|
MailRule,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("paperless_mail.view_mailrule")
|
if request.user.has_perm("paperless_mail.view_mailrule")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
mail_rules = mail_rules[:OBJECT_LIMIT]
|
mail_rules = mail_rules[:OBJECT_LIMIT]
|
||||||
mail_accounts = (
|
mail_accounts = (
|
||||||
restrict_queryset_to_visible(
|
get_objects_for_user_owner_aware(
|
||||||
MailAccount.objects.filter(name__icontains=query),
|
|
||||||
request.user,
|
request.user,
|
||||||
"view_mailaccount",
|
"view_mailaccount",
|
||||||
)
|
MailAccount,
|
||||||
|
).filter(name__icontains=query)
|
||||||
if request.user.has_perm("paperless_mail.view_mailaccount")
|
if request.user.has_perm("paperless_mail.view_mailaccount")
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
@@ -4028,33 +4026,33 @@ class StatisticsView(GenericAPIView[Any]):
|
|||||||
tags = (
|
tags = (
|
||||||
Tag.objects.all()
|
Tag.objects.all()
|
||||||
if can_view_global_stats
|
if can_view_global_stats
|
||||||
else restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
|
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
||||||
).only("id", "is_inbox_tag")
|
).only("id", "is_inbox_tag")
|
||||||
correspondent_count = (
|
correspondent_count = (
|
||||||
Correspondent.objects.count()
|
Correspondent.objects.count()
|
||||||
if can_view_global_stats
|
if can_view_global_stats
|
||||||
else restrict_queryset_to_visible(
|
else get_objects_for_user_owner_aware(
|
||||||
Correspondent.objects.all(),
|
|
||||||
user,
|
user,
|
||||||
"view_correspondent",
|
"documents.view_correspondent",
|
||||||
|
Correspondent,
|
||||||
).count()
|
).count()
|
||||||
)
|
)
|
||||||
document_type_count = (
|
document_type_count = (
|
||||||
DocumentType.objects.count()
|
DocumentType.objects.count()
|
||||||
if can_view_global_stats
|
if can_view_global_stats
|
||||||
else restrict_queryset_to_visible(
|
else get_objects_for_user_owner_aware(
|
||||||
DocumentType.objects.all(),
|
|
||||||
user,
|
user,
|
||||||
"view_documenttype",
|
"documents.view_documenttype",
|
||||||
|
DocumentType,
|
||||||
).count()
|
).count()
|
||||||
)
|
)
|
||||||
storage_path_count = (
|
storage_path_count = (
|
||||||
StoragePath.objects.count()
|
StoragePath.objects.count()
|
||||||
if can_view_global_stats
|
if can_view_global_stats
|
||||||
else restrict_queryset_to_visible(
|
else get_objects_for_user_owner_aware(
|
||||||
StoragePath.objects.all(),
|
|
||||||
user,
|
user,
|
||||||
"view_storagepath",
|
"documents.view_storagepath",
|
||||||
|
StoragePath,
|
||||||
).count()
|
).count()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from django.utils.translation import gettext as _
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.permissions import has_perms_owner_aware
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
|
||||||
from documents.serialisers import CorrespondentField
|
from documents.serialisers import CorrespondentField
|
||||||
from documents.serialisers import DocumentTypeField
|
from documents.serialisers import DocumentTypeField
|
||||||
from documents.serialisers import OwnedObjectSerializer
|
from documents.serialisers import OwnedObjectSerializer
|
||||||
@@ -68,10 +68,10 @@ class AccountField(serializers.PrimaryKeyRelatedField[MailAccount]):
|
|||||||
if user is None:
|
if user is None:
|
||||||
return MailAccount.objects.none()
|
return MailAccount.objects.none()
|
||||||
|
|
||||||
return restrict_queryset_to_visible(
|
return get_objects_for_user_owner_aware(
|
||||||
MailAccount.objects.all(),
|
|
||||||
user,
|
user,
|
||||||
"change_mailaccount",
|
"change_mailaccount",
|
||||||
|
MailAccount,
|
||||||
).order_by("-id")
|
).order_by("-id")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1272,11 +1272,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.32.6"
|
version = "4.0.12"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3184,7 +3184,7 @@ requires-dist = [
|
|||||||
{ name = "drf-spectacular", specifier = "~=0.30" },
|
{ name = "drf-spectacular", specifier = "~=0.30" },
|
||||||
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
|
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
|
||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "filelock", specifier = "~=3.32.0" },
|
{ name = "filelock", specifier = ">=3.32,<4.1" },
|
||||||
{ name = "flower", specifier = ">=2.0.1,<2.3" },
|
{ name = "flower", specifier = ">=2.0.1,<2.3" },
|
||||||
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
|
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
|
||||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
|
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
|
||||||
|
|||||||
Reference in new issue
Block a user