conference-website/conference_main/admin.py
Anna Sirota 1de50ba72e Models: various fixes to the RecordModificationMixin
The following was fixed: RecordModificationMixin used to presume
the record's fields are changing even when `update_fields` imply they aren't.

Admin now shows the list of `LogEntry`s, and links to it
in addition to the `History` tab.
2024-05-13 19:09:12 +02:00

695 lines
22 KiB
Python

from typing import List, Tuple, Any
from django import forms
from django.contrib import admin
from django.contrib import messages
from django.contrib.admin.filters import SimpleListFilter
from django.contrib.admin.models import LogEntry, DELETION
from django.contrib.admin.options import IncorrectLookupParameters
from django.contrib.admin.views.main import ChangeList
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.flatpages.admin import FlatPageAdmin
from django.contrib.flatpages.models import FlatPage
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from django.db import models as django_models
from django.db.models import QuerySet, Sum, F, Value, Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.http import HttpRequest
from django.http.response import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render
from django.urls import path
from django.urls import reverse
from django.utils.encoding import force_str
from django.utils.html import escape
from django.utils.html import format_html
from django.utils.safestring import mark_safe, SafeText
from django.utils.translation import ngettext
from django.views.generic.base import View
from tinymce.widgets import AdminTinyMCE
from conference_main import models, permissions, widgets
from conference_main.forms import LocationForm
from conference_main.admin_mixins import ExportCsvMixin
admin.site.enable_nav_sidebar = False
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 = repr(obj) if obj else text
return format_html(f'<a href="{url}">{text}</a>')
return ''
class PreFilteredListFilter(SimpleListFilter):
"""Reusable admin filtering.
Via Greg and JohnGalt on SO.
"""
# Either set this or override .get_default_value()
default_value = None
no_filter_value = 'all'
no_filter_name = 'All'
# Human-readable title which will be displayed in the
# right admin sidebar just above the filter options.
title = None
# Parameter for the filter that will be used in the URL query.
parameter_name = None
def get_default_value(self):
if self.default_value is not None:
return self.default_value
raise NotImplementedError(
'Either the .default_value attribute needs to be set or '
'the .get_default_value() method must be overridden to '
'return a URL query argument for parameter_name.'
)
def get_lookups(self) -> List[Tuple[Any, str]]:
"""
Returns a list of tuples. The first element in each
tuple is the coded value for the option that will
appear in the URL query. The second element is the
human-readable name for the option that will appear
in the right sidebar.
"""
raise NotImplementedError(
'The .get_lookups() method must be overridden to '
'return a list of tuples (value, verbose value).'
)
# Overriding parent class:
def lookups(self, request, model_admin) -> List[Tuple[Any, str]]:
return [(self.no_filter_value, self.no_filter_name)] + self.get_lookups()
# Overriding parent class:
def queryset(self, request, queryset: QuerySet) -> QuerySet:
"""
Returns the filtered queryset based on the value
provided in the query string and retrievable via
`self.value()`.
"""
if self.value() is None:
return self.get_default_queryset(queryset)
if self.value() == self.no_filter_value:
return queryset.all()
return self.get_filtered_queryset(queryset)
def get_default_queryset(self, queryset: QuerySet) -> QuerySet:
return queryset.filter(**{self.parameter_name: self.get_default_value()})
def get_filtered_queryset(self, queryset: QuerySet) -> QuerySet:
try:
return queryset.filter(**self.used_parameters)
except (ValueError, ValidationError) as e:
# Fields may raise a ValueError or ValidationError when converting
# the parameters to the correct type.
raise IncorrectLookupParameters(e)
# Overriding parent class:
def choices(self, changelist: ChangeList):
"""
Overridden to prevent the default "All".
"""
value = self.value() or force_str(self.get_default_value())
for lookup, title in self.lookup_choices:
yield {
'selected': value == force_str(lookup),
'query_string': changelist.get_query_string({self.parameter_name: lookup}),
'display': title,
}
class StatusFilter(PreFilteredListFilter):
default_value = models.Event.Statuses.ACCEPTED
title = 'status'
parameter_name = 'status__exact'
def get_lookups(self):
return [s for s in models.Event.Statuses.choices]
class EditionFilter(PreFilteredListFilter):
title = 'edition'
parameter_name = 'edition__id__exact'
def get_lookups(self):
return [(e.id, e.path) for e in models.Edition.objects.all().order_by('-path')]
def get_default_value(self):
return models.SiteSettings.objects.first().current_edition.id
class EventsAdminForm(forms.ModelForm):
"""Event Admin Form
Override the default admin form to filter the days available so
they match the current Edition.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.instance.pk:
return
self.fields['day'].queryset = models.Day.objects.filter(edition=self.instance.edition)
@admin.register(models.Tag)
class TagsAdmin(admin.ModelAdmin):
search_fields = ['name']
prepopulated_fields = {'slug': ('name',)}
@admin.register(models.Location)
class LocationsAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
form = LocationForm
list_display = ['name', 'slug', 'order', 'color']
class FavoriteInlineAdmin(admin.TabularInline):
model = models.Event.favorites.through
raw_id_fields = ['user']
verbose_name = 'Favorite'
verbose_name_plural = 'Favorites'
extra = 0
class AttendeeInlineAdmin(admin.TabularInline):
model = models.Event.attendees.through
raw_id_fields = ['user']
verbose_name = 'Attendee'
verbose_name_plural = 'Attendees'
extra = 0
class IsScheduledFilter(PreFilteredListFilter):
title = 'scheduled'
parameter_name = 'is_scheduled'
default_value = 'all'
def get_lookups(self):
return [
('Yes', 'Yes'),
('No', 'No'),
]
def queryset(self, request, queryset):
value = self.value()
if value == 'Yes':
return queryset.filter(day__isnull=False, time__isnull=False)
elif value == 'No':
return queryset.filter(day__isnull=True, time__isnull=True)
return queryset
@admin.register(models.Event)
class EventsAdmin(admin.ModelAdmin, ExportCsvMixin):
save_on_top = True
list_display = (
'id',
'name',
'get_speaker_links',
'category',
'location',
'get_tags',
'favorites_count',
'attendees_count',
'status',
'is_scheduled',
'show_link',
)
fields = (
'name',
'description',
'speakers',
'tags',
'picture',
'category',
'location',
'day',
'time',
'duration_minutes',
'website',
'recording',
'slides_url',
'proposal',
'user',
'status',
'edition',
)
inlines = [FavoriteInlineAdmin, AttendeeInlineAdmin]
actions = ['export_as_csv']
readonly_fields = ('proposal',)
list_filter = (EditionFilter, StatusFilter, IsScheduledFilter, 'category', 'location')
search_fields = ('name', 'description', 'speakers__full_name')
form = EventsAdminForm
formfield_overrides = {
django_models.CharField: {'widget': forms.TextInput(attrs={'size': '93'})},
}
def get_urls(self):
"""Return URLs of additional admin views, such as printable view."""
urls = super().get_urls()
model_name = f'{self.model._meta.app_label}_{self.model._meta.model_name}'
extra_urls = [
path(
'printable/',
self.printable_view,
name=f'{model_name}_printable',
),
]
return extra_urls + urls
def printable_view(self, request):
cl = self.get_changelist_instance(request)
return render(request, 'admin/printable_events.pug', {'events': cl.queryset})
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.prefetch_related('tags')
queryset = queryset.annotate(
favorites_count=Count('favorites', distinct=True),
attendees_count=Count('attendees', distinct=True),
)
return queryset
def favorites_count(self, obj):
return obj.favorites_count
favorites_count.admin_order_field = 'favorites_count'
favorites_count.short_description = 'Favs'
def attendees_count(self, obj):
return obj.attendees_count
attendees_count.admin_order_field = 'attendees_count'
attendees_count.short_description = 'Going'
# TODO(fsiddi) Improve this function with better working and url construction
def show_link(self, obj):
return mark_safe('<a href="%s">Review</a>' % obj.get_review_url())
show_link.short_description = 'View'
def get_speaker_links(self, obj: models.Event) -> str:
if not obj.speakers.all():
return "-"
speaker_links = []
for profile in obj.speakers.all():
full_name: str = profile.full_name or profile.user.username
speaker_links.append(f"<a href='{obj.user.profile.get_absolute_url()}'>{full_name}</a>")
return format_html(", ".join(speaker_links))
get_speaker_links.short_description = 'Speaker(s)'
def get_tags(self, obj: models.Event) -> str:
tags = [t.name for t in obj.tags.all()]
return ', '.join(tags)
get_tags.short_description = 'Tags'
exclude = ('picture_height', 'picture_width', 'favorites')
autocomplete_fields = ['speakers', 'user', 'tags']
date_hierarchy = 'day__date'
# Enable "View on Site" button
view_on_site = True
def date(self, event: models.Event) -> str:
if not event.day:
return ''
return str(event.day.date)
def is_scheduled(self, event: models.Event) -> bool:
return True if event.day and event.time else False
is_scheduled.short_description = 'Scheduled'
is_scheduled.boolean = True
def save_model(self, request: Any, obj: models.Event, form: Any, change: Any) -> None:
if obj.status == obj.Statuses.ACCEPTED and not obj.duration_minutes:
messages.set_level(request, messages.ERROR)
messages.error(request, 'Could not save: Approved events must have a duration')
return
super(EventsAdmin, self).save_model(request, obj, form, change)
@admin.register(models.Profile)
class ProfilesAdmin(admin.ModelAdmin):
list_display = [
'id',
'full_name',
'user',
'company',
'country',
'is_public',
]
list_filter = [
'user__tickets__edition',
'user__tickets__is_paid',
'user__tickets__is_free',
'is_public',
]
readonly_fields = ['user']
search_fields = ['full_name', 'user__email', 'user__username', 'country']
# Enable "View on Site" button
view_on_site = True
class FestivalEntryFilter(PreFilteredListFilter):
title = 'edition'
parameter_name = 'edition__id__exact'
def get_lookups(self):
return [(e.id, e.path) for e in models.Edition.objects.all().order_by('-path')]
def get_default_value(self):
return models.SiteSettings.objects.first().current_edition.id
class FestivalEntryVotesAdmin(admin.TabularInline):
model = models.FestivalEntryVotes
readonly_fields = ['user', 'rating']
extra = 0
verbose_name = 'Vote'
verbose_name_plural = 'Votes'
classes = ['collapse']
def has_delete_permission(self, request, obj=None):
return False
def has_add_permission(self, request, obj=None):
return False
class FestivalEntryFinalVotesAdmin(admin.TabularInline):
model = models.FestivalEntryFinalVotes
readonly_fields = ['user', 'points']
extra = 0
verbose_name = 'Final Vote'
verbose_name_plural = 'Final Votes'
classes = ['collapse']
def has_delete_permission(self, request, obj=None):
return False
def has_add_permission(self, request, obj=None):
return False
@admin.register(models.FestivalEntry)
class FestivalEntryAdmin(admin.ModelAdmin):
save_on_top = True
inlines = [FestivalEntryVotesAdmin, FestivalEntryFinalVotesAdmin]
list_display = (
'title',
'show_link',
'user',
'category',
'status',
'popularity',
'score',
'has_thumbnail',
)
list_filter = (FestivalEntryFilter, 'status', 'category')
search_fields = ('title', 'description')
actions = ['make_accepted']
autocomplete_fields = ['user']
# Enable "View on Site" button
view_on_site = True
@admin.action(description='Mark selected entries as accepted')
def make_accepted(self, request, queryset):
updated = queryset.update(status='accepted')
self.message_user(
request,
ngettext(
'%d entry was successfully marked as accepted.',
'%d entries were successfully marked as accepted.',
updated,
)
% updated,
messages.SUCCESS,
)
def get_queryset(self, request: HttpRequest) -> 'QuerySet[models.FestivalEntry]':
qs = super().get_queryset(request)
return qs.annotate(
# Caveat: because we need to aggregate 2 fields, .annotate() won't work here,
# returning nonsensical results because it uses a join.
popularity=Coalesce(
Subquery(
models.FestivalEntryVotes.objects.filter(festival_entry_id=OuterRef('pk'))
.values('festival_entry_id')
.annotate(popularity=Coalesce(Sum(F('rating')), Value(0)))
.values('popularity')
),
Value(0),
),
score=Coalesce(
Subquery(
models.FestivalEntryFinalVotes.objects.filter(festival_entry_id=OuterRef('pk'))
.values('festival_entry_id')
.annotate(score=Coalesce(Sum(F('points')), Value(0)))
.values('score')
),
Value(0),
),
)
def show_link(self, obj: models.FestivalEntry) -> SafeText:
return mark_safe(f'<a href="{obj.get_absolute_url()}">View</a>')
show_link.short_description = 'View'
def popularity(self, obj: models.FestivalEntry) -> int:
popularity: int = obj.popularity # type: ignore
return popularity
popularity.admin_order_field = 'popularity' # type: ignore
popularity.short_description = mark_safe('Popularity<br>(sum of ratings)') # type: ignore
def score(self, obj: models.FestivalEntry) -> int:
score: int = obj.score # type: ignore
return score
score.admin_order_field = 'score' # type: ignore
def has_thumbnail(self, obj: models.FestivalEntry) -> bool:
return bool(obj.thumbnail)
has_thumbnail.short_description = 'Thumbnail?'
has_thumbnail.boolean = True
@admin.register(models.Message)
class MessageAdmin(admin.ModelAdmin):
autocomplete_fields = ['user']
search_fields = ('title', 'content')
list_display = (
'id',
'user',
'content',
'created_at',
)
class TinyMCEFlatFileImageUploadView(LoginRequiredMixin, PermissionRequiredMixin, View):
def has_permission(self) -> bool:
return permissions.can_add_flatfile(self.request.user)
def post(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse:
if not request.FILES:
return HttpResponseBadRequest('No file provided.')
elif len(request.FILES) > 1:
return HttpResponseBadRequest('Multiple files provided.')
else:
file: UploadedFile = next(iter(request.FILES.values()))
flatfile: models.FlatFile = models.FlatFile.objects.create(
file=file, type=models.FlatFile.IMAGE
)
return JsonResponse({'location': flatfile.file.url})
class TinyMCEFlatFileImageListView(LoginRequiredMixin, PermissionRequiredMixin, View):
def has_permission(self) -> bool:
return self.request.user.has_perm('can_view_flatfile')
def get(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse:
return JsonResponse(
[
{'title': str(image), 'value': str(image.file.url)}
for image in models.FlatFile.objects.filter(type=models.FlatFile.IMAGE)
],
safe=False,
)
class TinyMCEFlatPagesLinkListView(LoginRequiredMixin, PermissionRequiredMixin, View):
def has_permission(self) -> bool:
return self.request.user.has_perm('can_view_flatpage')
def get(self, request: HttpRequest, *args: str, **kwargs: str) -> HttpResponse:
return JsonResponse(
[
{'title': str(flatpage), 'value': str(flatpage.get_absolute_url())}
for flatpage in FlatPage.objects.all()
]
+ [
{'title': str(flatfile), 'value': str(flatfile.file.url)}
for flatfile in models.FlatFile.objects.filter(type=models.FlatFile.BINARY)
],
safe=False,
)
class TinyMCEFlatPageAdmin(FlatPageAdmin): # type: ignore
def formfield_for_dbfield(self, db_field, **kwargs):
if db_field.name == 'content':
return db_field.formfield(
widget=AdminTinyMCE(
mce_attrs={
'link_list': reverse('tinymce_flatpages_link_list'),
'image_list': reverse('tinymce_flatfile_image_list'),
'automatic_uploads': True,
'images_reuse_filename': True,
'images_upload_handler': 'tinyMCEImageUploadHandler',
}
)
)
return super(TinyMCEFlatPageAdmin, self).formfield_for_dbfield(db_field, **kwargs)
class FlatFileImageForm(forms.ModelForm):
class Meta:
model = models.FlatFile
fields = ('file', 'type')
widgets = {'file': widgets.ImageWidget}
@admin.register(models.FlatFile)
class FlatFileAdmin(admin.ModelAdmin):
def get_form(self, request, obj=None, change=False, **kwargs):
if change and obj.type == models.FlatFile.IMAGE:
kwargs['form'] = FlatFileImageForm
return super().get_form(request, obj, change, **kwargs)
@admin.register(models.Photo)
class PhotoAdmin(admin.ModelAdmin):
date_hierarchy = 'created_at'
raw_id_fields = ['user']
list_filter = ['edition', 'albums']
list_display = ['created_at', 'file', 'user']
search_fields = ['user__email', 'user__username', 'user__profile__full_name', 'hash']
readonly_fields = ['hash']
@admin.register(models.Album)
class AlbumAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('title',)}
view_on_site = True
date_hierarchy = 'created_at'
raw_id_fields = ['photos']
list_filter = ['edition', 'is_upload_open']
list_display = ['created_at', 'title', 'slug', 'cover_image']
class SponsorsInlineAdmin(admin.TabularInline):
model = models.Sponsor
verbose_name = 'Sponsor'
verbose_name_plural = 'Sponsors'
extra = 1
@admin.register(models.Edition)
class EditionAdmin(admin.ModelAdmin):
save_on_top = True
inlines = [SponsorsInlineAdmin]
list_display = ('year', 'is_archived')
@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"
admin.site.register(models.Day)
admin.site.register(models.SiteSettings)
admin.site.unregister(FlatPage)
admin.site.register(FlatPage, TinyMCEFlatPageAdmin)
admin.site.register(models.SponsorLevel)