Skip to content

Commit 97200a3

Browse files
Merge pull request #12 from sergei-maertens/issue/10-readonly-fields
Support read only private media fields
2 parents 9ab8fee + 98844d2 commit 97200a3

File tree

9 files changed

+146
-2
lines changed

9 files changed

+146
-2
lines changed

privates/admin.py

+28
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,31 @@ def get_urls(self):
142142
)
143143

144144
return extra + default
145+
146+
def get_object(self, request, object_id, from_field=None):
147+
"""
148+
Override to replace the readonly private media file field accessors.
149+
150+
See issue #10 - injecting the private media (admin specific) URLs for readonly
151+
fields is not possible since all the form field machinery gets bypassed, and
152+
a bunch of helpers/utilities that are private Django API are used to render a
153+
form, fieldsets, field lines and ultimately the field. This call chain ends up
154+
in ``django.db.models.fields.file.FieldFile.url``. We cannot blindly always
155+
return the admin URL at the model level, since these URLs should not be exposed
156+
to non-admin users.
157+
158+
So, in the admin context we instead taint the model instance so that our custom
159+
``FieldFile`` class/attr_class can introspect this and conditionally build up
160+
the correct URL.
161+
"""
162+
obj = super().get_object(request, object_id, from_field=from_field) # type: ignore
163+
164+
readonly_fields = self.get_readonly_fields(request, obj=obj) # type: ignore
165+
obj._private_media_readonly_fields = [
166+
field
167+
for field in self.get_private_media_fields()
168+
if field in readonly_fields
169+
]
170+
obj._private_media_model_admin = self
171+
172+
return obj

privates/fields.py

+37-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,40 @@
1-
from django.db.models import FileField, ImageField
1+
from __future__ import annotations
2+
3+
from django.db.models import FileField, ImageField, Model
4+
from django.db.models.fields.files import FieldFile, ImageFieldFile
5+
from django.urls import reverse
26

37
from .storages import private_media_storage
48

59

10+
class PrivateMediaFieldFileMixin:
11+
instance: Model
12+
13+
@property
14+
def url(self) -> str:
15+
django_url = super().url # type: ignore
16+
17+
readonly_fields = getattr(self.instance, "_private_media_readonly_fields", None)
18+
model_admin = getattr(self.instance, "_private_media_model_admin", None)
19+
if not readonly_fields or not model_admin:
20+
return django_url
21+
22+
field_name = self.field.name # type: ignore
23+
if field_name not in readonly_fields:
24+
return django_url
25+
26+
url_name = f"admin:{model_admin._get_private_media_view_name(field_name)}"
27+
return reverse(url_name, kwargs={"pk": self.instance.pk})
28+
29+
30+
class PrivateMediaFieldFile(PrivateMediaFieldFileMixin, FieldFile):
31+
pass
32+
33+
34+
class PrivateMediaImageFieldFile(PrivateMediaFieldFileMixin, ImageFieldFile):
35+
pass
36+
37+
638
class PrivateMediaFieldMixin:
739
"""
840
Enable a private media storage for file-based model fields.
@@ -25,8 +57,12 @@ class PrivateMediaFileField(PrivateMediaFieldMixin, FileField):
2557
A generic private media file field.
2658
"""
2759

60+
attr_class = PrivateMediaFieldFile
61+
2862

2963
class PrivateMediaImageField(PrivateMediaFieldMixin, ImageField):
3064
"""
3165
A private media image field.
3266
"""
67+
68+
attr_class = PrivateMediaImageFieldFile

privates/storages.py

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ class PrivateMediaFileSystemStorage(FileSystemStorage):
1212
* ``settings.PRIVATE_MEDIA_URL`` is the internal URL used for files.
1313
"""
1414

15+
_location: str | None
16+
_base_url: str | None
17+
1518
def _clear_cached_properties(self, setting, **kwargs):
1619
super()._clear_cached_properties(setting, **kwargs)
1720
if setting == "PRIVATE_MEDIA_ROOT":

testapp/admin.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from privates.admin import PrivateMediaMixin
44

5-
from .models import File, File2, File3
5+
from .models import File, File2, File3, File4
66

77

88
@admin.register(File)
@@ -20,3 +20,8 @@ class FileAdmin2(FileAdmin):
2020
@admin.register(File3)
2121
class File3Admin(PrivateMediaMixin, admin.ModelAdmin):
2222
pass
23+
24+
25+
@admin.register(File4)
26+
class File4Admin(PrivateMediaMixin, admin.ModelAdmin):
27+
readonly_fields = ("file",)
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 5.1.6 on 2025-02-26 12:29
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("testapp", "0003_file2"),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="File3",
15+
fields=[],
16+
options={
17+
"proxy": True,
18+
"indexes": [],
19+
"constraints": [],
20+
},
21+
bases=("testapp.file",),
22+
),
23+
migrations.CreateModel(
24+
name="File4",
25+
fields=[],
26+
options={
27+
"proxy": True,
28+
"indexes": [],
29+
"constraints": [],
30+
},
31+
bases=("testapp.file",),
32+
),
33+
]

testapp/models.py

+5
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,8 @@ class Meta:
1818
class File3(File):
1919
class Meta:
2020
proxy = True
21+
22+
23+
class File4(File):
24+
class Meta:
25+
proxy = True

testapp/settings.py

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22

3+
DEBUG = True
4+
35
USE_TZ = True
46

57
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
@@ -19,6 +21,7 @@
1921
"django.contrib.contenttypes",
2022
"django.contrib.auth",
2123
"django.contrib.messages",
24+
"django.contrib.staticfiles",
2225
"django.contrib.sessions",
2326
"django.contrib.admin",
2427
"privates",
@@ -51,6 +54,8 @@
5154
},
5255
]
5356

57+
STATIC_URL = "static/"
58+
5459
PRIVATE_MEDIA_ROOT = os.path.join(BASE_DIR, "private_media")
5560
PRIVATE_MEDIA_URL = "/protected/"
5661

tests/test_admin.py

+17
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def test_admin_widget_url_empty_initial(admin_client):
4747
def test_admin_widget_url_inmemoryfile(admin_client):
4848
url = reverse("admin:testapp_file_add")
4949
response = admin_client.post(url, {"file": BytesIO(b"")}, follow=True)
50+
assert response.status_code == 200
5051

5152

5253
@pytest.mark.django_db
@@ -75,3 +76,19 @@ def test_admin_no_download_field(admin_client, private_file):
7576
display_value = img.text.strip()
7677

7778
assert display_value == _("Currently: %s") % private_file.image.name
79+
80+
81+
@pytest.mark.django_db
82+
def test_admin_readonly_field(admin_client, private_file):
83+
"""
84+
Assert that readonly files have the correct download URL.
85+
"""
86+
url = reverse("admin:testapp_file4_change", args=(private_file.pk,))
87+
change_page = admin_client.get(url)
88+
assert change_page.status_code == 200
89+
html = change_page.content.decode("utf-8")
90+
doc = pq(html)
91+
download_link = doc.find(".readonly a").eq(0)
92+
assert download_link.attr("href") == reverse(
93+
"admin:testapp_file4_file", args=(private_file.pk,)
94+
)

tests/test_modelfield.py

+12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from django.conf import settings
22

3+
import pytest
4+
35
from privates.storages import PrivateMediaStorage
46
from testapp.models import File
57

@@ -8,3 +10,13 @@ def test_private_media_file_field():
810
file = File()
911
assert isinstance(file.file.storage, PrivateMediaStorage)
1012
assert file.file.storage.base_location == settings.PRIVATE_MEDIA_ROOT
13+
14+
15+
@pytest.mark.django_db
16+
def test_url_property(private_file):
17+
# outside if the admin, the URL property must use django's default behaviour
18+
url = private_file.file.url
19+
20+
assert url.startswith("/protected/")
21+
assert "dummy" in url
22+
assert url.endswith(".txt")

0 commit comments

Comments
 (0)