Code

Ticket #342: readonly-admin.5.diff

File readonly-admin.5.diff, 21.4 KB (added by fest, 4 years ago)

Fixed label tag generation to use pretty_name instead of capfirst, which now produces pretty field names when underscores are used.

Line 
1Index: django/contrib/admin/helpers.py
2===================================================================
3--- django/contrib/admin/helpers.py     (revision 11726)
4+++ django/contrib/admin/helpers.py     (working copy)
5@@ -1,13 +1,15 @@
6-
7 from django import forms
8 from django.conf import settings
9+from django.contrib.admin.util import flatten_fieldsets
10+from django.contrib.contenttypes.models import ContentType
11+from django.forms.util import flatatt
12+from django.utils.encoding import force_unicode
13 from django.utils.html import escape
14 from django.utils.safestring import mark_safe
15-from django.utils.encoding import force_unicode
16-from django.contrib.admin.util import flatten_fieldsets
17-from django.contrib.contenttypes.models import ContentType
18+from django.utils.text import capfirst
19 from django.utils.translation import ugettext_lazy as _
20 
21+
22 ACTION_CHECKBOX_NAME = '_selected_action'
23 
24 class ActionForm(forms.Form):
25@@ -16,16 +18,18 @@
26 checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
27 
28 class AdminForm(object):
29-    def __init__(self, form, fieldsets, prepopulated_fields):
30+    def __init__(self, form, fieldsets, prepopulated_fields, readonly_fields):
31         self.form, self.fieldsets = form, normalize_fieldsets(fieldsets)
32         self.prepopulated_fields = [{
33             'field': form[field_name],
34             'dependencies': [form[f] for f in dependencies]
35         } for field_name, dependencies in prepopulated_fields.items()]
36+        self.readonly_fields = readonly_fields
37 
38     def __iter__(self):
39         for name, options in self.fieldsets:
40-            yield Fieldset(self.form, name, **options)
41+            yield Fieldset(self.form, name,
42+                readonly_fields=self.readonly_fields, **options)
43 
44     def first_field(self):
45         try:
46@@ -49,11 +53,13 @@
47     media = property(_media)
48 
49 class Fieldset(object):
50-    def __init__(self, form, name=None, fields=(), classes=(), description=None):
51+    def __init__(self, form, name=None, readonly_fields=(), fields=(), classes=(),
52+        description=None):
53         self.form = form
54         self.name, self.fields = name, fields
55         self.classes = u' '.join(classes)
56         self.description = description
57+        self.readonly_fields = readonly_fields
58 
59     def _media(self):
60         if 'collapse' in self.classes:
61@@ -63,22 +69,26 @@
62 
63     def __iter__(self):
64         for field in self.fields:
65-            yield Fieldline(self.form, field)
66+            yield Fieldline(self.form, field, self.readonly_fields)
67 
68 class Fieldline(object):
69-    def __init__(self, form, field):
70+    def __init__(self, form, field, readonly_fields):
71         self.form = form # A django.forms.Form instance
72         if isinstance(field, basestring):
73             self.fields = [field]
74         else:
75             self.fields = field
76+        self.readonly_fields = readonly_fields
77 
78     def __iter__(self):
79         for i, field in enumerate(self.fields):
80-            yield AdminField(self.form, field, is_first=(i == 0))
81+            if field in self.readonly_fields:
82+                yield AdminReadonlyField(self.form, field, is_first=(i == 0))
83+            else:
84+                yield AdminField(self.form, field, is_first=(i == 0))
85 
86     def errors(self):
87-        return mark_safe(u'\n'.join([self.form[f].errors.as_ul() for f in self.fields]).strip('\n'))
88+        return mark_safe(u'\n'.join([self.form[f].errors.as_ul() for f in self.fields if f not in self.readonly_fields]).strip('\n'))
89 
90 class AdminField(object):
91     def __init__(self, form, field, is_first):
92@@ -100,20 +110,44 @@
93         attrs = classes and {'class': u' '.join(classes)} or {}
94         return self.field.label_tag(contents=contents, attrs=attrs)
95 
96+class AdminReadonlyField(object):
97+    def __init__(self, form, field, is_first):
98+        self.field = field
99+        self.form = form
100+        self.is_first = is_first
101+        self.is_checkbox = False
102+        self.is_readonly = True
103+   
104+    def label_tag(self):
105+        attrs = {}
106+        if not self.is_first:
107+            attrs["class"] = "inline"
108+        contents = force_unicode(escape(forms.forms.pretty_name(self.field))) + u":"
109+        return mark_safe('<label %(attrs)s>%(contents)s</label>' % {
110+            "attrs": flatatt(attrs),
111+            "contents": contents,
112+        })
113+   
114+    def contents(self):
115+        return getattr(self.form.instance, self.field)
116+
117 class InlineAdminFormSet(object):
118     """
119     A wrapper around an inline formset for use in the admin system.
120     """
121-    def __init__(self, inline, formset, fieldsets):
122+    def __init__(self, inline, formset, fieldsets, readonly_fields):
123         self.opts = inline
124         self.formset = formset
125         self.fieldsets = fieldsets
126+        self.readonly_fields = readonly_fields
127 
128     def __iter__(self):
129         for form, original in zip(self.formset.initial_forms, self.formset.get_queryset()):
130-            yield InlineAdminForm(self.formset, form, self.fieldsets, self.opts.prepopulated_fields, original)
131+            yield InlineAdminForm(self.formset, form, self.fieldsets,
132+                self.opts.prepopulated_fields, original, self.readonly_fields)
133         for form in self.formset.extra_forms:
134-            yield InlineAdminForm(self.formset, form, self.fieldsets, self.opts.prepopulated_fields, None)
135+            yield InlineAdminForm(self.formset, form, self.fieldsets,
136+                self.opts.prepopulated_fields, None, self.readonly_fields)
137 
138     def fields(self):
139         fk = getattr(self.formset, "fk", None)
140@@ -133,17 +167,19 @@
141     """
142     A wrapper around an inline form for use in the admin system.
143     """
144-    def __init__(self, formset, form, fieldsets, prepopulated_fields, original):
145+    def __init__(self, formset, form, fieldsets, prepopulated_fields, original,
146+        readonly_fields):
147         self.formset = formset
148         self.original = original
149         if original is not None:
150             self.original_content_type_id = ContentType.objects.get_for_model(original).pk
151         self.show_url = original and hasattr(original, 'get_absolute_url')
152-        super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields)
153+        super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields,
154+            readonly_fields)
155 
156     def __iter__(self):
157         for name, options in self.fieldsets:
158-            yield InlineFieldset(self.formset, self.form, name, **options)
159+            yield InlineFieldset(self.formset, self.form, name, self.readonly_fields, **options)
160 
161     def has_auto_field(self):
162         if self.form._meta.model._meta.has_auto_field:
163@@ -194,7 +230,7 @@
164         for field in self.fields:
165             if fk and fk.name == field:
166                 continue
167-            yield Fieldline(self.form, field)
168+            yield Fieldline(self.form, field, self.readonly_fields)
169 
170 class AdminErrorList(forms.util.ErrorList):
171     """
172Index: django/contrib/admin/options.py
173===================================================================
174--- django/contrib/admin/options.py     (revision 11726)
175+++ django/contrib/admin/options.py     (working copy)
176@@ -65,6 +65,7 @@
177     radio_fields = {}
178     prepopulated_fields = {}
179     formfield_overrides = {}
180+    readonly_fields = ()
181 
182     def __init__(self):
183         self.formfield_overrides = dict(FORMFIELD_FOR_DBFIELD_DEFAULTS, **self.formfield_overrides)
184@@ -173,6 +174,9 @@
185             return [(None, {'fields': self.fields})]
186         return None
187     declared_fieldsets = property(_declared_fieldsets)
188+   
189+    def get_readonly_fields(self, request, obj=None):
190+        return self.readonly_fields
191 
192 class ModelAdmin(BaseModelAdmin):
193     "Encapsulates all admin options and functionality for a given model."
194@@ -323,7 +327,8 @@
195         if self.declared_fieldsets:
196             return self.declared_fieldsets
197         form = self.get_form(request, obj)
198-        return [(None, {'fields': form.base_fields.keys()})]
199+        fields = form.base_fields.keys() + list(self.get_readonly_fields(request, obj))
200+        return [(None, {'fields': fields})]
201 
202     def get_form(self, request, obj=None, **kwargs):
203         """
204@@ -338,12 +343,15 @@
205             exclude = []
206         else:
207             exclude = list(self.exclude)
208+        exclude.extend(kwargs.get("exclude", []))
209+        exclude.extend(self.get_readonly_fields(request, obj))
210         # if exclude is an empty list we pass None to be consistant with the
211         # default on modelform_factory
212+        exclude = exclude or None
213         defaults = {
214             "form": self.form,
215             "fields": fields,
216-            "exclude": (exclude + kwargs.get("exclude", [])) or None,
217+            "exclude": exclude,
218             "formfield_callback": curry(self.formfield_for_dbfield, request=request),
219         }
220         defaults.update(kwargs)
221@@ -763,13 +771,17 @@
222                 formset = FormSet(instance=self.model(), prefix=prefix)
223                 formsets.append(formset)
224 
225-        adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)), self.prepopulated_fields)
226+        adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)),
227+            self.prepopulated_fields, self.get_readonly_fields(request)
228+        )
229         media = self.media + adminForm.media
230 
231         inline_admin_formsets = []
232         for inline, formset in zip(self.inline_instances, formsets):
233             fieldsets = list(inline.get_fieldsets(request))
234-            inline_admin_formset = helpers.InlineAdminFormSet(inline, formset, fieldsets)
235+            readonly = list(inline.get_readonly_fields(request))
236+            inline_admin_formset = helpers.InlineAdminFormSet(inline, formset,
237+                fieldsets, readonly)
238             inline_admin_formsets.append(inline_admin_formset)
239             media = media + inline_admin_formset.media
240 
241@@ -852,13 +864,16 @@
242                 formset = FormSet(instance=obj, prefix=prefix)
243                 formsets.append(formset)
244 
245-        adminForm = helpers.AdminForm(form, self.get_fieldsets(request, obj), self.prepopulated_fields)
246+        adminForm = helpers.AdminForm(form, self.get_fieldsets(request, obj),
247+            self.prepopulated_fields, self.get_readonly_fields(request, obj))
248         media = self.media + adminForm.media
249 
250         inline_admin_formsets = []
251         for inline, formset in zip(self.inline_instances, formsets):
252             fieldsets = list(inline.get_fieldsets(request, obj))
253-            inline_admin_formset = helpers.InlineAdminFormSet(inline, formset, fieldsets)
254+            readonly = list(inline.get_readonly_fields(request, obj))
255+            inline_admin_formset = helpers.InlineAdminFormSet(inline, formset,
256+                fieldsets, readonly)
257             inline_admin_formsets.append(inline_admin_formset)
258             media = media + inline_admin_formset.media
259 
260@@ -1150,14 +1165,17 @@
261             exclude = []
262         else:
263             exclude = list(self.exclude)
264+        exclude.extend(kwargs.get("exclude", []))
265+        exclude.extend(self.get_readonly_fields(request, obj))
266         # if exclude is an empty list we use None, since that's the actual
267         # default
268+        exclude = exclude or None
269         defaults = {
270             "form": self.form,
271             "formset": self.formset,
272             "fk_name": self.fk_name,
273             "fields": fields,
274-            "exclude": (exclude + kwargs.get("exclude", [])) or None,
275+            "exclude": exclude,
276             "formfield_callback": curry(self.formfield_for_dbfield, request=request),
277             "extra": self.extra,
278             "max_num": self.max_num,
279@@ -1169,7 +1187,8 @@
280         if self.declared_fieldsets:
281             return self.declared_fieldsets
282         form = self.get_formset(request).form
283-        return [(None, {'fields': form.base_fields.keys()})]
284+        fields = form.base_fields.keys() + list(self.get_readonly_fields(request, obj))
285+        return [(None, {'fields': fields})]
286 
287 class StackedInline(InlineModelAdmin):
288     template = 'admin/edit_inline/stacked.html'
289Index: django/contrib/admin/validation.py
290===================================================================
291--- django/contrib/admin/validation.py  (revision 11726)
292+++ django/contrib/admin/validation.py  (working copy)
293@@ -122,6 +122,17 @@
294             if '__' in field:
295                 continue
296             get_field(cls, model, opts, 'ordering[%d]' % idx, field)
297+   
298+    if hasattr(cls, "readonly_fields"):
299+        check_isseq(cls, "readonly_fields", cls.readonly_fields)
300+        for idx, field in enumerate(cls.readonly_fields):
301+            try:
302+                field = opts.get_field_by_name(field)[0]
303+            except models.FieldDoesNotExist:
304+                raise ImproperlyConfigured("'%s.readonly_fields[%d]' refers to a "
305+                    "field, '%s', not defined on %s."
306+                    % (cls.__name__, idx, field, model.__name__))
307+           
308 
309     # list_select_related = False
310     # save_as = False
311Index: django/contrib/admin/templates/admin/includes/fieldset.html
312===================================================================
313--- django/contrib/admin/templates/admin/includes/fieldset.html (revision 11726)
314+++ django/contrib/admin/templates/admin/includes/fieldset.html (working copy)
315@@ -1,19 +1,28 @@
316 <fieldset class="module aligned {{ fieldset.classes }}">
317-  {% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
318-  {% if fieldset.description %}<div class="description">{{ fieldset.description|safe }}</div>{% endif %}
319-  {% for line in fieldset %}
320-      <div class="form-row{% if line.errors %} errors{% endif %} {% for field in line %}{{ field.field.name }} {% endfor %} ">
321-      {{ line.errors }}
322-      {% for field in line %}
323-      <div{% if not line.fields|length_is:"1" %} class="field-box"{% endif %}>
324-          {% if field.is_checkbox %}
325-              {{ field.field }}{{ field.label_tag }}
326-          {% else %}
327-              {{ field.label_tag }}{{ field.field }}
328-          {% endif %}
329-          {% if field.field.field.help_text %}<p class="help">{{ field.field.field.help_text|safe }}</p>{% endif %}
330-      </div>
331-      {% endfor %}
332-      </div>
333-  {% endfor %}
334+    {% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
335+    {% if fieldset.description %}
336+        <div class="description">{{ fieldset.description|safe }}</div>
337+    {% endif %}
338+    {% for line in fieldset %}
339+        <div class="form-row{% if line.errors %} errors{% endif %} {% for field in line %}{{ field.field.name }} {% endfor %} ">
340+            {{ line.errors }}
341+            {% for field in line %}
342+                <div{% if not line.fields|length_is:"1" %} class="field-box"{% endif %}>
343+                    {% if field.is_checkbox %}
344+                        {{ field.field }}{{ field.label_tag }}
345+                    {% else %}
346+                        {{ field.label_tag }}
347+                        {% if field.is_readonly %}
348+                            {{ field.contents }}
349+                        {% else %}
350+                            {{ field.field }}
351+                        {% endif %}
352+                    {% endif %}
353+                    {% if field.field.field.help_text %}
354+                        <p class="help">{{ field.field.field.help_text|safe }}</p>
355+                    {% endif %}
356+                </div>
357+            {% endfor %}
358+        </div>
359+    {% endfor %}
360 </fieldset>
361Index: docs/ref/contrib/admin/index.txt
362===================================================================
363--- docs/ref/contrib/admin/index.txt    (revision 11726)
364+++ docs/ref/contrib/admin/index.txt    (working copy)
365@@ -540,6 +540,15 @@
366     class ArticleAdmin(admin.ModelAdmin):
367         raw_id_fields = ("newspaper",)
368 
369+.. attribute:: ModelAdmin.readonly_fields
370+
371+By default any field Django's admin displays is shown as editable.  Any fields
372+in this option (which should be a ``list`` or ``tuple``) will be displayed as
373+just showing the data they contain, without the ability to be edited.  Note
374+that fields in this option shouldn't be in the ``fields`` or ``exclude``
375+options, they can however be in the ``fieldsets`` option to control where they
376+appear.
377+
378 .. attribute:: ModelAdmin.save_as
379 
380 Set ``save_as`` to enable a "save as" feature on admin change forms.
381@@ -744,6 +753,15 @@
382                 instance.save()
383             formset.save_m2m()
384 
385+.. method:: ModelAdmin.get_readonly_fields(self, request, obj=None)
386+
387+The ``get_readonly_fields`` method is given the ``HttpRequest`` and the
388+``obj`` being edited (or ``None`` on a add form) and is expected to return a
389+``list`` or ``tuple`` of field names that will be displayed as read-only, as
390+described above in the ``ModelAdmin.readonly_fields`` section.  Note that this
391+method will be called several times during a given request, therefore if it
392+performs and very expensive calculations it may be wise to cache them.
393+
394 .. method:: ModelAdmin.get_urls(self)
395 
396 .. versionadded:: 1.1
397Index: tests/regressiontests/admin_validation/models.py
398===================================================================
399--- tests/regressiontests/admin_validation/models.py    (revision 11726)
400+++ tests/regressiontests/admin_validation/models.py    (working copy)
401@@ -95,4 +95,18 @@
402 
403 >>> validate_inline(TwoAlbumFKAndAnEInline, None, Album)
404 
405+>>> class SongAdmin(admin.ModelAdmin):
406+...     readonly_fields = ("title",)
407+
408+>>> validate(SongAdmin, Song)
409+
410+>>> class SongAdmin(admin.ModelAdmin):
411+...     readonly_fields = ("title", "nonexistant")
412+
413+>>> validate(SongAdmin, Song)
414+Traceback (most recent call last):
415+    ...
416+ImproperlyConfigured: 'SongAdmin.readonly_fields[1]' refers to a field, 'nonexistant', not defined on Song.
417+
418+
419 """}
420Index: tests/regressiontests/admin_views/models.py
421===================================================================
422--- tests/regressiontests/admin_views/models.py (revision 11726)
423+++ tests/regressiontests/admin_views/models.py (working copy)
424@@ -1,11 +1,14 @@
425 # -*- coding: utf-8 -*-
426+import datetime
427 import tempfile
428 import os
429+
430+from django.contrib import admin
431 from django.core.files.storage import FileSystemStorage
432+from django.core.mail import EmailMessage
433 from django.db import models
434-from django.contrib import admin
435-from django.core.mail import EmailMessage
436 
437+
438 class Section(models.Model):
439     """
440     A simple section that links to articles, to test linking to related items
441@@ -420,6 +423,14 @@
442 class CollectorAdmin(admin.ModelAdmin):
443     inlines = [WidgetInline, DooHickeyInline, GrommetInline, WhatsitInline, FancyDoodadInline, CategoryInline]
444 
445+class Post(models.Model):
446+    title = models.CharField(max_length=100)
447+    content = models.TextField()
448+    posted = models.DateField(default=datetime.date.today)
449+
450+class PostAdmin(admin.ModelAdmin):
451+    readonly_fields = ('posted',)
452+
453 admin.site.register(Article, ArticleAdmin)
454 admin.site.register(CustomArticle, CustomArticleAdmin)
455 admin.site.register(Section, save_as=True, inlines=[ArticleInline])
456@@ -443,6 +454,7 @@
457 admin.site.register(Recommender)
458 admin.site.register(Collector, CollectorAdmin)
459 admin.site.register(Category, CategoryAdmin)
460+admin.site.register(Post, PostAdmin)
461 
462 # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
463 # That way we cover all four cases:
464Index: tests/regressiontests/admin_views/tests.py
465===================================================================
466--- tests/regressiontests/admin_views/tests.py  (revision 11726)
467+++ tests/regressiontests/admin_views/tests.py  (working copy)
468@@ -18,12 +18,8 @@
469     ExternalSubscriber, FooAccount, Gallery, ModelWithStringPrimaryKey, \
470     Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
471     Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
472-    Category
473+    Category, Post
474 
475-try:
476-    set
477-except NameError:
478-    from sets import Set as set
479 
480 class AdminViewBasicTest(TestCase):
481     fixtures = ['admin-views-users.xml', 'admin-views-colors.xml', 'admin-views-fabrics.xml']
482@@ -1627,3 +1623,39 @@
483         "Check the never-cache status of the Javascript i18n view"
484         response = self.client.get('/test_admin/jsi18n/')
485         self.failUnlessEqual(get_max_age(response), None)
486+
487+
488+class ReadonlyTest(TestCase):
489+    fixtures = ['admin-views-users.xml']
490+
491+    def setUp(self):
492+        self.client.login(username='super', password='secret')
493+
494+    def tearDown(self):
495+        self.client.logout()
496+
497+    def test_readonly_get(self):
498+        response = self.client.get('/test_admin/admin/admin_views/post/add/')
499+        self.assertEqual(response.status_code, 200)
500+        self.assertNotContains(response, 'name="posted"')
501+        # 3 fields + 2 submit buttons
502+        self.assertEqual(response.content.count("input"), 5)
503+        self.assertContains(response, str(datetime.date.today()))
504+   
505+    def test_readonly_post(self):
506+        data = {
507+            "title": "Django Got Readonly Fields",
508+            "content": "This is an incredible development."
509+        }
510+        response = self.client.post('/test_admin/admin/admin_views/post/add/', data)
511+        self.assertEqual(response.status_code, 302)
512+        self.assertEqual(Post.objects.count(), 1)
513+        p = Post.objects.get()
514+        self.assertEqual(p.posted, datetime.date.today())
515+       
516+        data["posted"] = "10-8-1990" # some date that's not today
517+        response = self.client.post('/test_admin/admin/admin_views/post/add/', data)
518+        self.assertEqual(response.status_code, 302)
519+        self.assertEqual(Post.objects.count(), 2)
520+        p = Post.objects.order_by('-id')[0]
521+        self.assertEqual(p.posted, datetime.date.today())