Skip to content

Commit 01702d8

Browse files
authored
feat: Add cache for permission checks (#1486)
1 parent 3d29fdd commit 01702d8

File tree

11 files changed

+312
-3
lines changed

11 files changed

+312
-3
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ jobs:
3939
run: echo "CUSTOM_IMAGE=custom_image.Image" >> $GITHUB_ENV
4040
if: ${{ matrix.custom-image-model }}
4141
- name: Run coverage
42-
run: coverage run setup.py test
42+
run: coverage run tests/settings.py
4343
- name: Upload Coverage to Codecov
4444
uses: codecov/codecov-action@v1

filer/admin/folderadmin.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from easy_thumbnails.models import Thumbnail
2929

3030
from .. import settings
31+
from ..cache import clear_folder_permission_cache
3132
from ..models import File, Folder, FolderPermission, FolderRoot, ImagesWithMissingData, UnsortedImages, tools
3233
from ..settings import FILER_IMAGE_MODEL, FILER_PAGINATE_BY, TABLE_LIST_TYPE
3334
from ..thumbnail_processors import normalize_subject_location
@@ -107,6 +108,9 @@ def save_form(self, request, form, change):
107108
Given a ModelForm return an unsaved instance. ``change`` is True if
108109
the object is being changed, and False if it's being added.
109110
"""
111+
if not change:
112+
# New folder invalidates the folder permission cache (or it will not be visible)
113+
clear_folder_permission_cache(request.user)
110114
r = form.save(commit=False)
111115
parent_id = request.GET.get('parent_id', None)
112116
if not parent_id:

filer/admin/permissionadmin.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.utils.translation import gettext_lazy as _
33

44
from .. import settings
5+
from ..cache import clear_folder_permission_cache
56

67

78
class PermissionAdmin(admin.ModelAdmin):
@@ -31,3 +32,11 @@ def get_model_perms(self, request):
3132
'change': enable_permissions,
3233
'delete': enable_permissions,
3334
}
35+
36+
def save_model(self, request, obj, form, change):
37+
clear_folder_permission_cache(request.user)
38+
super().save_model(request, obj, form, change)
39+
40+
def delete_model(self, request, obj):
41+
clear_folder_permission_cache(request.user)
42+
super().delete_model(request, obj)

filer/cache.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import typing
2+
3+
from django.core.cache import cache
4+
5+
from django.contrib.auth import get_user_model
6+
7+
8+
User = get_user_model()
9+
10+
11+
def get_folder_perm_cache_key(user: User, permission: str) -> str:
12+
"""
13+
Generates a unique cache key for a given user and permission.
14+
15+
The key is a string in the format "filer:perm:<permission>", i.e. it does not
16+
contain the user id. This will be sufficient for most use cases.
17+
18+
Patch this method to include the user id in the cache key if necessary, e.g.,
19+
for far more than 1,000 admin users to make the cached unit require less memory.
20+
21+
Parameters:
22+
user (User): The user for whom the cache key is being generated.
23+
permission (str): The permission for which the cache key is being generated.
24+
25+
Returns:
26+
str: The generated cache key.
27+
"""
28+
return f"filer:perm:{permission}"
29+
30+
31+
def get_folder_permission_cache(user: User, permission: str) -> typing.Optional[dict]:
32+
"""
33+
Retrieves the cached folder permissions for a given user and permission.
34+
35+
If the cache value exists, it returns the permissions for the user.
36+
If the cache value does not exist, it returns None.
37+
38+
Parameters:
39+
user (User): The user for whom the permissions are being retrieved.
40+
permission (str): The permission for which the permissions are being retrieved.
41+
42+
Returns:
43+
dict or None: The permissions for the user, or None if no cache value exists.
44+
"""
45+
cache_value = cache.get(get_folder_perm_cache_key(user, permission))
46+
if cache_value:
47+
return cache_value.get(user.pk, None)
48+
return None
49+
50+
51+
def clear_folder_permission_cache(user: User, permission: typing.Optional[str] = None) -> None:
52+
"""
53+
Clears the cached folder permissions for a given user.
54+
55+
If a specific permission is provided, it clears the cache for that permission only.
56+
If no specific permission is provided, it clears the cache for all permissions.
57+
58+
Parameters:
59+
user (User): The user for whom the permissions are being cleared.
60+
permission (str, optional): The specific permission to clear. Defaults to None.
61+
"""
62+
if permission is None:
63+
for perm in ['can_read', 'can_edit', 'can_add_children']:
64+
cache.delete(get_folder_perm_cache_key(user, perm))
65+
else:
66+
cache.delete(get_folder_perm_cache_key(user, permission))
67+
68+
69+
def update_folder_permission_cache(user: User, permission: str, id_list: list[int]) -> None:
70+
"""
71+
Updates the cached folder permissions for a given user and permission.
72+
73+
It first retrieves the current permissions from the cache (or an empty dictionary if none exist).
74+
Then it updates the permissions for the user with the provided list of IDs.
75+
Finally, it sets the updated permissions back into the cache.
76+
77+
Parameters:
78+
user (User): The user for whom the permissions are being updated.
79+
permission (str): The permission to update.
80+
id_list (list): The list of IDs to set as the new permissions.
81+
"""
82+
perms = get_folder_permission_cache(user, permission) or {}
83+
perms[user.pk] = id_list
84+
cache.set(get_folder_perm_cache_key(user, permission), perms)
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Generated by Django 3.2.25 on 2024-08-19 14:49
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import filer.fields.multistorage_file
7+
import filer.models.filemodels
8+
import filer.models.mixins
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
replaces = [('filer', '0001_initial'), ('filer', '0002_auto_20150606_2003'), ('filer', '0003_thumbnailoption'), ('filer', '0004_auto_20160328_1434'), ('filer', '0005_auto_20160623_1425'), ('filer', '0006_auto_20160623_1627'), ('filer', '0007_auto_20161016_1055'), ('filer', '0008_auto_20171117_1313'), ('filer', '0009_auto_20171220_1635'), ('filer', '0010_auto_20180414_2058'), ('filer', '0011_auto_20190418_0137'), ('filer', '0012_file_mime_type'), ('filer', '0013_image_width_height_to_float'), ('filer', '0014_folder_permission_choices'), ('filer', '0015_alter_file_owner_alter_file_polymorphic_ctype_and_more'), ('filer', '0016_alter_folder_index_together_remove_folder_level_and_more')]
14+
15+
initial = True
16+
17+
dependencies = [
18+
('contenttypes', '0001_initial'),
19+
('contenttypes', '0002_remove_content_type_name'),
20+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
21+
('auth', '0001_initial'),
22+
]
23+
24+
operations = [
25+
migrations.CreateModel(
26+
name='Clipboard',
27+
fields=[
28+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
29+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='filer_clipboards', to=settings.AUTH_USER_MODEL, verbose_name='user')),
30+
],
31+
options={
32+
'verbose_name': 'clipboard',
33+
'verbose_name_plural': 'clipboards',
34+
},
35+
),
36+
migrations.CreateModel(
37+
name='Folder',
38+
fields=[
39+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
40+
('name', models.CharField(max_length=255, verbose_name='name')),
41+
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='uploaded at')),
42+
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
43+
('modified_at', models.DateTimeField(auto_now=True, verbose_name='modified at')),
44+
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='filer_owned_folders', to=settings.AUTH_USER_MODEL, verbose_name='owner')),
45+
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='filer.folder', verbose_name='parent')),
46+
],
47+
options={
48+
'ordering': ('name',),
49+
'verbose_name': 'Folder',
50+
'verbose_name_plural': 'Folders',
51+
'permissions': (('can_use_directory_listing', 'Can use directory listing'),),
52+
'unique_together': {('parent', 'name')},
53+
},
54+
bases=(models.Model, filer.models.mixins.IconsMixin),
55+
),
56+
migrations.CreateModel(
57+
name='File',
58+
fields=[
59+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
60+
('file', filer.fields.multistorage_file.MultiStorageFileField(blank=True, max_length=255, null=True, upload_to=filer.fields.multistorage_file.generate_filename_multistorage, verbose_name='file')),
61+
('_file_size', models.BigIntegerField(blank=True, null=True, verbose_name='file size')),
62+
('sha1', models.CharField(blank=True, default='', max_length=40, verbose_name='sha1')),
63+
('has_all_mandatory_data', models.BooleanField(default=False, editable=False, verbose_name='has all mandatory data')),
64+
('original_filename', models.CharField(blank=True, max_length=255, null=True, verbose_name='original filename')),
65+
('name', models.CharField(blank=True, default='', max_length=255, verbose_name='name')),
66+
('description', models.TextField(blank=True, null=True, verbose_name='description')),
67+
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='uploaded at')),
68+
('modified_at', models.DateTimeField(auto_now=True, verbose_name='modified at')),
69+
('is_public', models.BooleanField(default=filer.models.filemodels.is_public_default, help_text='Disable any permission checking for this file. File will be publicly accessible to anyone.', verbose_name='Permissions disabled')),
70+
('folder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='all_files', to='filer.folder', verbose_name='folder')),
71+
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='owner')),
72+
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
73+
('mime_type', models.CharField(default='application/octet-stream', help_text='MIME type of uploaded content', max_length=255, validators=[filer.models.filemodels.mimetype_validator])),
74+
],
75+
options={
76+
'verbose_name': 'file',
77+
'verbose_name_plural': 'files',
78+
},
79+
bases=(models.Model, filer.models.mixins.IconsMixin),
80+
),
81+
migrations.CreateModel(
82+
name='ClipboardItem',
83+
fields=[
84+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
85+
('clipboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='filer.clipboard', verbose_name='clipboard')),
86+
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='filer.file', verbose_name='file')),
87+
],
88+
options={
89+
'verbose_name': 'clipboard item',
90+
'verbose_name_plural': 'clipboard items',
91+
},
92+
),
93+
migrations.CreateModel(
94+
name='ThumbnailOption',
95+
fields=[
96+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
97+
('name', models.CharField(max_length=100, verbose_name='name')),
98+
('width', models.IntegerField(help_text='width in pixel.', verbose_name='width')),
99+
('height', models.IntegerField(help_text='height in pixel.', verbose_name='height')),
100+
('crop', models.BooleanField(default=True, verbose_name='crop')),
101+
('upscale', models.BooleanField(default=True, verbose_name='upscale')),
102+
],
103+
options={
104+
'ordering': ('width', 'height'),
105+
'verbose_name': 'thumbnail option',
106+
'verbose_name_plural': 'thumbnail options',
107+
},
108+
),
109+
migrations.AddField(
110+
model_name='clipboard',
111+
name='files',
112+
field=models.ManyToManyField(related_name='in_clipboards', through='filer.ClipboardItem', to='filer.File', verbose_name='files'),
113+
),
114+
migrations.CreateModel(
115+
name='FolderPermission',
116+
fields=[
117+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
118+
('type', models.SmallIntegerField(choices=[(0, 'all items'), (1, 'this item only'), (2, 'this item and all children')], default=0, verbose_name='type')),
119+
('everybody', models.BooleanField(default=False, verbose_name='everybody')),
120+
('can_edit', models.SmallIntegerField(blank=True, choices=[(None, 'inherit'), (1, 'allow'), (0, 'deny')], default=None, null=True, verbose_name='can edit')),
121+
('can_read', models.SmallIntegerField(blank=True, choices=[(None, 'inherit'), (1, 'allow'), (0, 'deny')], default=None, null=True, verbose_name='can read')),
122+
('can_add_children', models.SmallIntegerField(blank=True, choices=[(None, 'inherit'), (1, 'allow'), (0, 'deny')], default=None, null=True, verbose_name='can add children')),
123+
('folder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='filer.folder', verbose_name='folder')),
124+
('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='filer_folder_permissions', to='auth.group', verbose_name='group')),
125+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='filer_folder_permissions', to=settings.AUTH_USER_MODEL, verbose_name='user')),
126+
],
127+
options={
128+
'verbose_name': 'folder permission',
129+
'verbose_name_plural': 'folder permissions',
130+
},
131+
),
132+
migrations.CreateModel(
133+
name='Image',
134+
fields=[
135+
('file_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='%(app_label)s_%(class)s_file', serialize=False, to='filer.file')),
136+
('_height', models.FloatField(blank=True, null=True)),
137+
('_width', models.FloatField(blank=True, null=True)),
138+
('date_taken', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='date taken')),
139+
('default_alt_text', models.CharField(blank=True, max_length=255, null=True, verbose_name='default alt text')),
140+
('default_caption', models.CharField(blank=True, max_length=255, null=True, verbose_name='default caption')),
141+
('author', models.CharField(blank=True, max_length=255, null=True, verbose_name='author')),
142+
('must_always_publish_author_credit', models.BooleanField(default=False, verbose_name='must always publish author credit')),
143+
('must_always_publish_copyright', models.BooleanField(default=False, verbose_name='must always publish copyright')),
144+
('subject_location', models.CharField(blank=True, default='', max_length=64, verbose_name='subject location')),
145+
],
146+
options={
147+
'swappable': 'FILER_IMAGE_MODEL',
148+
'verbose_name': 'image',
149+
'verbose_name_plural': 'images',
150+
'default_manager_name': 'objects',
151+
},
152+
bases=('filer.file',),
153+
),
154+
]

filer/migrations/0012_file_mime_type.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,5 @@ class Migration(migrations.Migration):
2828
name='mime_type',
2929
field=models.CharField(default='application/octet-stream', help_text='MIME type of uploaded content', max_length=255, validators=[filer.models.filemodels.mimetype_validator]),
3030
),
31-
migrations.RunPython(guess_mimetypes, reverse_code=migrations.RunPython.noop),
31+
migrations.RunPython(guess_mimetypes, reverse_code=migrations.RunPython.noop, elidable=True),
3232
]

filer/models/foldermodels.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from .. import settings as filer_settings
1313
from . import mixins
14+
from ..cache import get_folder_permission_cache, update_folder_permission_cache
1415

1516

1617
class FolderPermissionManager(models.Manager):
@@ -34,6 +35,10 @@ def get_add_children_id_list(self, user):
3435
def __get_id_list(self, user, attr):
3536
if user.is_superuser or not filer_settings.FILER_ENABLE_PERMISSIONS:
3637
return 'All'
38+
cached_id_list = get_folder_permission_cache(user, attr)
39+
if cached_id_list:
40+
return cached_id_list
41+
3742
allow_list = set()
3843
deny_list = set()
3944
group_ids = user.groups.all().values_list('id', flat=True)
@@ -71,7 +76,9 @@ def __get_id_list(self, user, attr):
7176
deny_list.update(perm.folder.get_descendants_ids())
7277

7378
# Deny has precedence over allow
74-
return allow_list - deny_list
79+
id_list = allow_list - deny_list
80+
update_folder_permission_cache(user, attr, id_list)
81+
return id_list
7582

7683

7784
class Folder(models.Model, mixins.IconsMixin):

tests/requirements/django-main.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
-r base.txt
22

33
git+https://github.com/django/django@main#egg=Django
4+
git+https://github.com/SmileyChris/easy-thumbnails@master#egg=easy-thumbnails
45
django_polymorphic>=3.1

tests/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
}
6969
}
7070
},
71+
'THUMBNAIL_DEFAULT_STORAGE_ALIAS': 'default', # for the lack of any other storage defined
7172
'SECRET_KEY': '__secret__',
7273
'DEFAULT_AUTO_FIELD': 'django.db.models.AutoField',
7374
}

tests/test_admin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,6 +1395,8 @@ def test_with_permission_given_to_parent_folder(self):
13951395
can_edit=FolderPermission.ALLOW,
13961396
can_read=FolderPermission.ALLOW,
13971397
can_add_children=FolderPermission.ALLOW)
1398+
from filer.cache import clear_folder_permission_cache
1399+
clear_folder_permission_cache(self.staff_user)
13981400
response = self.client.get(
13991401
reverse('admin:filer-directory_listing',
14001402
kwargs={'folder_id': self.parent.id}))

0 commit comments

Comments
 (0)