Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9a9ff54
wip: install filebrowser
paigewilliams Nov 18, 2025
954e198
customize filebrowser browse method
paigewilliams Nov 18, 2025
e1c2364
override filebrowser/detail.html
paigewilliams Nov 19, 2025
79e4ad6
display related media records on media details page
paigewilliams Nov 19, 2025
0eec276
override filebrowser/index.html
paigewilliams Nov 19, 2025
e858496
try filebrowser settings
paigewilliams Nov 19, 2025
3d380de
add admin_thumbnail to filebrowser versions
paigewilliams Nov 19, 2025
3a24547
ruff format and check
paigewilliams Nov 19, 2025
7ef5cbb
add filter for has media record or not
paigewilliams Nov 19, 2025
73ff5e6
add view and template for confirming no media record delete
paigewilliams Nov 20, 2025
750bede
delete media files in bulk
paigewilliams Nov 20, 2025
f75b0d5
remove print statements
paigewilliams Nov 20, 2025
fab7cbf
remove image versions
paigewilliams Nov 21, 2025
82dd59b
Merge branch 'main' into filebrowser
paigewilliams Nov 21, 2025
13dff22
exclude filebrowser from datadump command
paigewilliams Nov 21, 2025
e80af4f
remove unused js
paigewilliams Nov 21, 2025
ee5baa3
add tests for custom_filebrowser
paigewilliams Nov 22, 2025
333e809
code cleanup
paigewilliams Nov 24, 2025
13a91e9
clean up logic in TekdbFileBrowserSite.browse
paigewilliams Nov 24, 2025
e6364ee
update tests; fix typos
paigewilliams Nov 24, 2025
71e5d7a
use specific exceptions
paigewilliams Nov 24, 2025
f81afbe
pin django-filebrowser-no-grappelli version
paigewilliams Nov 24, 2025
decf92e
add comments for template in filebrowser library
paigewilliams Nov 24, 2025
2b0a270
set FILEBROWSER_VERSIONS to empty dict
paigewilliams Nov 24, 2025
19e1fb4
Merge branch 'main' into filebrowser
paigewilliams Dec 5, 2025
e5001f0
remove FILEBROWSER_EXTENSIONS from settings
paigewilliams Dec 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions TEKDB/TEKDB/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,24 @@
class TEKDBConfig(AppConfig):
name = "TEKDB"
verbose_name = "Records"

def ready(self):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the right place for adding methods to FileObject?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully @rhodges will know better than me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not seen anything like this before, but the more I dig into your code (and the FileBrowser source) this makes a lot of sense.

Ideally we'd want to just override FileObject to include these, but since we don't explicitly define any FileObjects, we'd need to override whichever parent or ancestor that we do touch, and by the end we may have a very difficult to maintain 'stack of crap' (my speciality) just to override one deeply embedded class. There is likely a lower-common-denominator where we could override the view that renders the template detail.html to insert the logic and context we need there, but in the end, if this works, I think it's just as elegant (though maybe harder to track & debug).

Knowing about this approach to modifying 3rd Party libraries is something I wish I'd known about long ago. If there are negative consequences, I don't know them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds like this is sticking around! 💩 thanks for the feedback!

from filebrowser.base import FileObject
from .models import Media

def get_related_media(self):
"""
Returns QuerySet of Media records that reference this file
The file field stores paths relative to MEDIA_ROOT
"""
# self.path gives the relative path from MEDIA_ROOT
# which should match what's stored in the mediafile field
return Media.objects.filter(mediafile=self.path)

def has_media_record(self):
"""Check if this file has any related Media records"""
return self.get_related_media().exists()

# Add methods to FileObject
FileObject.get_related_media = get_related_media
FileObject.has_media_record = has_media_record
23 changes: 23 additions & 0 deletions TEKDB/TEKDB/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
INSTALLED_APPS = [
"dal",
"dal_select2",
"filebrowser",
"django.contrib.contenttypes",
"django.contrib.admin",
"django.contrib.auth",
Expand Down Expand Up @@ -182,6 +183,28 @@

STATIC_ROOT = os.path.join(BASE_DIR, "static")

###########################################
## FILEBROWSER ###
###########################################
DIRECTORY = MEDIA_URL

FILEBROWSER_LIST_PER_PAGE = 20

FILEBROWSER_EXCLUDE = [] # Add extensions to exclude from showing in filebrowser. E.g. ['.py',]

FILEBROWSER_VERSIONS = {} # Empty dict because we are not using versions

# Apps to exclude from the export `dumpdata` command. This prevents
# dumping third-party apps (like `filebrowser`) that may not have tables
# in the test DB or that you don't want included in project backups.
EXPORT_DUMP_EXCLUDE = [
"filebrowser",
]

###########################################
### END FILEBROWSER ###
###########################################

# STATICFILES_DIRS = [
# os.path.join(BASE_DIR, "explore", "static"),
# os.path.join(BASE_DIR, "TEKDB", "static"),
Expand Down
269 changes: 269 additions & 0 deletions TEKDB/TEKDB/tekdb_filebrowser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import os
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is TEKDB/TEKDB/tekdb_filebrowser.py the correct location for this file? I wasn't totally sure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure either, and have no objections.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for now.

In the future, I recommend borrowing the convention used by the library you're overriding. Since this mostly functions to override components and logic from filebrowser.sites, you could call this something like TEKDB/TEKDB/filebrowser/sites.py or filebrowser_sites.py. This is anticipating the potential that we may need to MASSIVELY override multiple components of another tool that are broken up between named files that we cannot anticipate (beyond 'views', 'models', 'admin' or other Django defaults).

from django.core.files.storage import DefaultStorage
from django.core.paginator import Paginator, EmptyPage
from django.template.response import TemplateResponse
from django.contrib import messages
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.conf import settings
from filebrowser import signals
from filebrowser.sites import (
FileBrowserSite,
filebrowser_view,
get_settings_var,
get_breadcrumbs,
admin_site,
)
from filebrowser.decorators import path_exists
from filebrowser.settings import (
DEFAULT_SORTING_BY,
DEFAULT_SORTING_ORDER,
VERSIONS_BASEDIR,
)
from filebrowser.templatetags.fb_tags import query_helper


def media_matches(media_filter, obj):
"""Return True if `obj` satisfies the media-record filter.
Supported values for media_filter:
- 'has_record' -> keep objects where obj.has_media_record() is True
- 'no_record' -> keep objects where obj.has_media_record() is False
If no media_filter is set, everything matches.
"""
if not media_filter:
return True
try:
has_attr = getattr(obj, "has_media_record", None)
if callable(has_attr):
has = bool(has_attr())
else:
has = bool(has_attr)
except AttributeError:
has = False
if media_filter == "has_record":
return has
if media_filter == "no_record":
return not has
return True


class TekdbFileBrowserSite(FileBrowserSite):
def files_folders_to_ignore(self):
"""Return list of filenames/folders to ignore in deleting media without record."""
return ["__pycache__", "__init__.py", VERSIONS_BASEDIR]

def get_urls(self):
from django.urls import re_path

urls = super().get_urls()

urls += [
re_path(
r"^delete-media-without-record-confirm/",
path_exists(
self, filebrowser_view(self.delete_media_without_record_confirm)
),
name="fb_delete_all_media_without_record_confirm",
),
re_path(
r"^delete-media-without-record/",
path_exists(self, filebrowser_view(self.delete_media_without_record)),
name="fb_delete_all_media_without_record",
),
]
return urls

def browse(self, request):
"""Call upstream browse(), then apply server-side filtering for the
`filter_media_record` query parameter. This keeps the UI filter backed
by server logic without changing upstream code.
"""
response = super().browse(request)

# Only proceed if the TemplateResponse has context data we can
# mutate.
if not hasattr(response, "context_data"):
return response

media_filter = None
try:
media_filter = request.GET.get("filter_media_record")
except AttributeError:
media_filter = None

# dict of original paginator -> new paginator
paginator_replacements = {}

# loop does the following:
# 1. filter data if media_filter is set
# 2. set response.context_data["page"] to filtered page
# 3. build new paginators dict
for key, val in list(response.context_data.items()):
try:
if (
hasattr(val, "paginator")
and hasattr(val, "number")
and key == "page"
):
orig_paginator = val.paginator
try:
full_list = list(orig_paginator.object_list)
except (AttributeError, TypeError):
full_list = list(getattr(val, "object_list", []))

filtered_full = [
obj for obj in full_list if media_matches(media_filter, obj)
]

max_per_page = getattr(settings, "FILEBROWSER_LIST_PER_PAGE", None)
if not max_per_page:
max_per_page = getattr(orig_paginator, "per_page", 20)

new_paginator = Paginator(filtered_full, max_per_page)
page_number = getattr(val, "number", 1)
try:
new_page = new_paginator.page(page_number)
except EmptyPage:
new_page = new_paginator.page(new_paginator.num_pages)
response.context_data[key] = new_page
paginator_replacements[orig_paginator] = new_paginator

except AttributeError:
# Non-fatal: skip entries we can't process
pass

# Replace response.context_data["p"] with new paginator
if paginator_replacements:
for key, orig_paginator in list(response.context_data.items()):
for orig, new_paginator in list(paginator_replacements.items()):
if orig_paginator is orig:
response.context_data[key] = new_paginator

# Update results_current and results_total
try:
filelisting = response.context_data.get("filelisting")
if filelisting is not None:
# If filelisting exposes a list of current files, try to
# update results_current/total conservatively.
try:
if hasattr(filelisting, "results_current"):
# get new page set on line 129
page = response.context_data.get("page")

if page is not None and hasattr(page, "object_list"):
# update results_current from filtered page
filelisting.results_current = len(list(page.object_list))

if hasattr(filelisting, "results_total"):
# derive results_total from a paginator replacement
paginator_new = None
for new_page in paginator_replacements.values():
paginator_new = new_page
break

if paginator_new is not None:
try:
total = len(getattr(paginator_new, "object_list", []))
except AttributeError:
total = 0

filelisting.results_total = total
except AttributeError:
pass
except (AttributeError, TypeError):
pass

return response

def delete_media_without_record_confirm(self, request):
"""Confirm page to delete selected files that do not have associated media records."""

query = request.GET
path = "%s" % os.path.join(self.directory, query.get("dir", ""))

filelisting = self.filelisting_class(
path,
filter_func=lambda fo: not fo.has_media_record()
and fo.filename not in self.files_folders_to_ignore(),
sorting_by=query.get("o", DEFAULT_SORTING_BY),
sorting_order=query.get("ot", DEFAULT_SORTING_ORDER),
site=self,
)
listings = filelisting.files_listing_filtered()

return TemplateResponse(
request,
"filebrowser/delete_no_media_record_confirm.html",
dict(
admin_site.each_context(request),
**{
"filelisting": listings,
"query": query,
"title": "Confirm Deletion of Unused Media Files",
"settings_var": get_settings_var(directory=self.directory),
"breadcrumbs": get_breadcrumbs(query, query.get("dir", "")),
"breadcrumbs_title": "Delete Unused Media Files",
"filebrowser_site": self,
},
),
)

def delete_media_without_record(self, request):
"""Delete selected files that do not have associated media records."""
query = request.GET
path = "%s" % os.path.join(self.directory, query.get("dir", ""))

filelisting = self.filelisting_class(
path,
filter_func=lambda fo: not fo.has_media_record()
and fo.filename not in self.files_folders_to_ignore(),
sorting_by=query.get("o", DEFAULT_SORTING_BY),
sorting_order=query.get("ot", DEFAULT_SORTING_ORDER),
site=self,
)
listing = filelisting.files_listing_filtered()

deleted_files = []
for fileobject in listing:
try:
signals.filebrowser_pre_delete.send(
sender=request,
path=fileobject.path,
name=fileobject.filename,
site=self,
)
# we have disabled versions for TEKDB,
# but keep the call here in case that changes in future
# or in the off chance versions exist
fileobject.delete_versions()
fileobject.delete()
deleted_files.append(fileobject.filename)
signals.filebrowser_post_delete.send(
sender=request,
path=fileobject.path,
name=fileobject.filename,
site=self,
)
except OSError:
messages.add_message(
request,
messages.ERROR,
f"Error deleting file: {fileobject.filename}",
)
break

messages.add_message(
request, messages.SUCCESS, f"Deleted files: {', '.join(deleted_files)}"
)

# Redirect back to browse after deletion
redirect_url = reverse(
"filebrowser:fb_browse", current_app=self.name
) + query_helper(query, "", "filename,filetype")
return HttpResponseRedirect(redirect_url)


storage = DefaultStorage()
site = TekdbFileBrowserSite(name="filebrowser", storage=storage)
site.directory = ""
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% extends "admin/base_site.html" %}

<!-- LOADING -->
{% load static i18n fb_tags fb_compat %}

<!-- BREADCRUMBS -->
{% block breadcrumbs %}{% include "filebrowser/include/breadcrumbs.html" %}{% endblock %}

<!-- CONTENT -->
{% block content %}
<!-- POP-UP BREADCRUMBS -->
{% if query.pop %}
{% include "filebrowser/include/breadcrumbs.html" %}
{% endif %}
{% if filelisting|length > 0 %}
<p>Are you sure you want to delete all media files without media records? All of the following related items will be deleted:</p>
{% if filelisting %}
<ul>
{% for fileobject in filelisting %}
<li>{{ fileobject.filename }}</li>
{% endfor %}
</ul>
{% endif %}

<form action="{% url 'filebrowser:fb_delete_all_media_without_record' %}{% query_string %}" method="post">
{% csrf_token %}
<div>
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
</div>
</form>
{% endif %}
{% if filelisting|length == 0 %}
<p>There are no media files without media records to delete.</p>
{% endif %}
{% endblock %}
Loading