extensions-website/common/admin.py
Anna Sirota b0bb4905b2 Reuse files as previews, icons or featured images (#161)
Now it should be be possible to:

* upload the same image as a preview or featured image on different extensions;
* upload the same image as an icon on different extensions;
* select the same video/image multiple times while adding previews on Draft or Edit page: first one will be saved, the rest of the duplicates will be ignored.

If all extensions referencing the file in any way are deleted, the file remains in the database: no thumbnail generating or scanning will happen if/when the file gets re-uploaded as a preview or featured image.

In all cases of re-upload `File.user` will not change: this shouldn't be a problem because currently there's no code relying on image ownership.

Version files will remain the only exception from this changed behaviour: it will only be possible to re-upload a version file once the version itself is deleted (which also deletes its file).

As a consequence of this change `File.extension_id` is dropped, because it is no longer possible to choose which extension should be saved there.

Should take care of #157

Reviewed-on: #161
Reviewed-by: Oleg-Komarov <oleg-komarov@noreply.localhost>
2024-06-04 12:23:25 +02:00

258 lines
9.2 KiB
Python

import functools
import operator
from django.contrib import admin
from django.contrib.admin.models import LogEntry, DELETION
from django.core.exceptions import FieldDoesNotExist
from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.urls import reverse
from django.utils.html import escape, format_html
def get_admin_change_path(obj=None, app_label=None, model_name=None, pk=None):
"""Return a path to the admin change page for a given object."""
if obj:
related_class = obj._meta.model
app_label = related_class._meta.app_label
model_name = related_class._meta.model_name
pk = obj.pk
if app_label and model_name and pk:
url_name = f'admin:{app_label}_{model_name}_change'
return reverse(url_name, kwargs={'object_id': pk})
def get_admin_change_url(obj=None, app_label=None, model_name=None, pk=None, text=''):
"""Return a link to the admin change page for a given object."""
url = get_admin_change_path(obj=obj, app_label=app_label, model_name=model_name, pk=pk)
if url:
text = str(obj) if obj else text
return format_html(f'<a href="{url}">{text}</a>')
return ''
def get_related_admin_changelist_url(
obj, related_class, related_field, related_manager='objects', text=None
):
"""
Return a link to the admin changelist for the instances of related_class
linked to the object.
"""
url = 'admin:{}_{}_changelist'.format(
related_class._meta.app_label, related_class._meta.model_name
)
if text is None:
qs = getattr(related_class, related_manager).filter(**{related_field: obj})
text = qs.count()
return format_html('<a href="{}?{}={}">{}</a>', reverse(url), related_field, obj.pk, text)
def get_related_admin_change_url(obj, related_field):
"""
Return a link to the admin change page for a related instance linked to the
object.
"""
instance = getattr(obj, related_field)
return get_admin_change_url(instance)
def link_to(field_name, title=None):
if not title:
title = field_name.replace('_', ' ')
related_field_name = None
if '.' in field_name:
field_name, related_field_name = field_name.split('.')
@admin.display(description=title, ordering=field_name)
def _raw(obj):
target_obj = getattr(obj, field_name)
if isinstance(target_obj, models.Manager):
admin_urls = []
for _obj in target_obj.all():
if related_field_name:
_obj = getattr(_obj, related_field_name)
admin_urls.append(get_admin_change_url(_obj))
return format_html('<br>'.join(admin_urls))
if related_field_name:
target_obj = getattr(target_obj, related_field_name)
admin_url = get_admin_change_url(target_obj)
return admin_url
_raw.__name__ = field_name
return _raw
class CommaSearchInAdminMixin:
def get_search_id_field(self, request):
"""
Return the field to use when all search terms are numeric.
Default is to return pk, but in some cases it'll make more sense to
return a foreign key.
"""
return 'pk'
def lookup_needs_distinct(self, opts, lookup_path):
"""
Return True if 'distinct()' should be used to query the given lookup
path. Used by get_search_results() as a replacement of the version used
by django, which doesn't consider our translation fields as needing
distinct (but they do).
"""
rval = admin.utils.lookup_needs_distinct(opts, lookup_path)
lookup_fields = lookup_path.split(LOOKUP_SEP)
# Not pretty but looking up the actual field would require truly
# resolving the field name, walking to any relations we find up until
# the last one, that would be a lot of work for a simple edge case.
if any(
field_name in lookup_fields
for field_name in ('localized_string', 'localized_string_clean')
):
rval = True
return rval
def get_search_results(self, request, queryset, search_term):
"""
Return a tuple containing a queryset to implement the search,
and a boolean indicating if the results may contain duplicates.
Originally copied from Django's, but with the following differences:
- The operator joining the query parts is dynamic: if the search term
contain a comma and no space, then the comma is used as the separator
instead, and the query parts are joined by OR, not AND, allowing
admins to search by a list of ids, emails or usernames and find all
objects in that list.
- If the search terms are all numeric and there is more than one, then
we also restrict the fields we search to the one returned by
get_search_id_field(request) using a __in ORM lookup directly.
"""
# Apply keyword searches.
def construct_search(field_name):
if field_name.startswith('^'):
return '%s__istartswith' % field_name[1:]
elif field_name.startswith('='):
return '%s__iexact' % field_name[1:]
elif field_name.startswith('@'):
return '%s__icontains' % field_name[1:]
# Use field_name if it includes a lookup.
opts = queryset.model._meta
lookup_fields = field_name.split(models.constants.LOOKUP_SEP)
# Go through the fields, following all relations.
prev_field = None
for path_part in lookup_fields:
if path_part == 'pk':
path_part = opts.pk.name
try:
field = opts.get_field(path_part)
except FieldDoesNotExist:
# Use valid query lookups.
if prev_field and prev_field.get_lookup(path_part):
return field_name
else:
prev_field = field
if hasattr(field, 'get_path_info'):
# Update opts to follow the relation.
opts = field.get_path_info()[-1].to_opts
# Otherwise, use the field with icontains.
return '%s__icontains' % field_name
may_have_duplicates = False
search_fields = self.get_search_fields(request)
filters = []
joining_operator = operator.and_
if not (search_fields and search_term):
# return early if we have nothing special to do
return queryset, may_have_duplicates
if ' ' not in search_term and ',' in search_term:
separator = ','
joining_operator = operator.or_
else:
separator = None
search_terms = search_term.split(separator)
all_numeric = all(term.isnumeric() for term in search_terms)
if all_numeric and len(search_terms) > 1:
# if we have multiple numbers assume we're doing a bulk id search
orm_lookup = '%s__in' % self.get_search_id_field(request)
queryset = queryset.filter(**{orm_lookup: search_terms})
else:
orm_lookups = [construct_search(str(search_field)) for search_field in search_fields]
for bit in search_terms:
or_queries = [models.Q(**{orm_lookup: bit}) for orm_lookup in orm_lookups]
q_for_this_term = models.Q(functools.reduce(operator.or_, or_queries))
filters.append(q_for_this_term)
may_have_duplicates |= any(
# Use our own lookup_needs_distinct(), not django's.
self.lookup_needs_distinct(self.opts, search_spec)
for search_spec in orm_lookups
)
if filters:
queryset = queryset.filter(functools.reduce(joining_operator, filters))
return queryset, may_have_duplicates
class NoAddDeleteMixin:
def has_add_permission(self, *args, **kwargs):
return False
def has_delete_permission(self, *args, **kwargs):
return False
@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin):
date_hierarchy = 'action_time'
list_filter = [
'action_flag',
'user__is_staff',
'user__is_superuser',
'action_time',
'content_type',
]
search_fields = [
'object_repr',
'change_message',
'user__email',
'user__username',
]
list_display = [
'action_time',
'user',
'content_type',
'object_link',
'action_flag',
'get_change_message',
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_view_permission(self, request, obj=None):
return request.user.is_superuser
def object_link(self, obj):
if obj.action_flag == DELETION:
return escape(obj.object_repr)
ct = obj.content_type
return get_admin_change_url(
app_label=ct.app_label,
model_name=ct.model,
text=escape(obj.object_repr),
pk=obj.object_id,
)
object_link.admin_order_field = "object_repr"
object_link.short_description = "object"