Closes #19591: Establish dedicated tab for image attachments (#19919)

* Initial work on #19591

* Ignore images cache directory

* Clean up thumbnails layout

* Include "add attachment" button

* Clean up ObjectImageAttachmentsView

* Add html_tag property to ImageAttachment

* Misc cleanup

* Collapse .gitignore files for /media

* Fix conditional in template
This commit is contained in:
Jeremy Stretch 2025-07-31 16:22:04 -04:00 committed by GitHub
parent 40dd36812c
commit 9a2fab1d48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 118 additions and 7 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ yarn-error.log*
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/local/*
/netbox/media
/netbox/reports/*
!/netbox/reports/__init__.py
/netbox/scripts/*

View File

@ -141,6 +141,10 @@ social-auth-app-django
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
social-auth-core
# Image thumbnail generation
# https://github.com/jazzband/sorl-thumbnail/blob/master/CHANGES.rst
sorl-thumbnail
# Strawberry GraphQL
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
strawberry-graphql

View File

@ -9,6 +9,8 @@ from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from rest_framework.utils.encoders import JSONEncoder
@ -728,6 +730,18 @@ class ImageAttachment(ChangeLoggedModel):
def filename(self):
return os.path.basename(self.image.name).split('_', 2)[2]
@property
def html_tag(self):
"""
Returns a complete <img> tag suitable for embedding in an HTML document.
"""
return mark_safe('<img src="{url}" height="{height}" width="{width}" alt="{alt_text}" />'.format(
url=self.image.url,
height=self.image_height,
width=self.image_width,
alt_text=escape(self.description or self.name),
))
@property
def size(self):
"""

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -736,6 +736,10 @@ def register_models(*models):
register_model_view(model, 'jobs', kwargs={'model': model})(
'netbox.views.generic.ObjectJobsView'
)
if issubclass(model, ImageAttachmentsMixin):
register_model_view(model, 'image-attachments', kwargs={'model': model})(
'netbox.views.generic.ObjectImageAttachmentsView'
)
if issubclass(model, SyncedDataMixin):
register_model_view(model, 'sync', kwargs={'model': model})(
'netbox.views.generic.ObjectSyncDataView'

View File

@ -424,6 +424,7 @@ INSTALLED_APPS = [
'mptt',
'rest_framework',
'social_django',
'sorl.thumbnail',
'taggit',
'timezone_field',
'core',

View File

@ -10,7 +10,7 @@ from django.views.generic import View
from core.models import Job, ObjectChange
from core.tables import JobTable, ObjectChangeTable
from extras.forms import JournalEntryForm
from extras.models import JournalEntry
from extras.models import ImageAttachment, JournalEntry
from extras.tables import JournalEntryTable
from tenancy.models import ContactAssignment
from tenancy.tables import ContactAssignmentTable
@ -25,6 +25,7 @@ __all__ = (
'BulkSyncDataView',
'ObjectChangeLogView',
'ObjectContactsView',
'ObjectImageAttachmentsView',
'ObjectJobsView',
'ObjectJournalView',
'ObjectSyncDataView',
@ -84,6 +85,41 @@ class ObjectChangeLogView(ConditionalLoginRequiredMixin, View):
})
class ObjectImageAttachmentsView(ConditionalLoginRequiredMixin, View):
"""
Render all images attached to the object as linked thumbnails.
Attributes:
base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
"""
base_template = None
tab = ViewTab(
label=_('Images'),
badge=lambda obj: obj.images.count(),
permission='extras.view_imageattachment',
weight=6000
)
def get(self, request, model, **kwargs):
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
image_attachments = ImageAttachment.objects.filter(
object_type=ContentType.objects.get_for_model(obj),
object_id=obj.pk,
)
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
# fall back to using base.html.
if self.base_template is None:
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
return render(request, 'extras/object_imageattachments.html', {
'object': obj,
'image_attachments': image_attachments,
'base_template': self.base_template,
'tab': self.tab,
})
class ObjectJournalView(ConditionalLoginRequiredMixin, View):
"""
Show all journal entries for an object. The model class must be passed as a keyword argument when referencing this

Binary file not shown.

View File

@ -81,6 +81,14 @@ img.plugin-icon {
height: auto;
}
// Image attachment thumbnails
.thumbnail {
max-width: 200px;
img {
border: 1px solid #606060;
}
}
body[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {

View File

@ -56,8 +56,8 @@
<div class="card">
<h2 class="card-header">{% trans "Image" %}</h2>
<div class="card-body">
<a href="{{ object.image.url }}">
<img src="{{ object.image.url }}" height="{{ image.height }}" width="{{ image.width }}" alt="{{ object }}" />
<a href="{{ object.image.url }}" title="{{ object.name }}">
{{ object.html_tag }}
</a>
</div>
</div>

View File

@ -0,0 +1,46 @@
{% extends base_template %}
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% load thumbnail %}
{% block extra_controls %}
{% if perms.extras.add_imageattachment %}
{% with viewname=object|viewname:"image-attachments" %}
<a href="{% url 'extras:imageattachment_add' %}?object_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Attach an Image" %}
</a>
{% endwith %}
{% endif %}
{% endblock %}
{% block content %}
{% if image_attachments %}
<div class="d-flex flex-wrap">
{% for object in image_attachments %}
<div class="thumbnail m-2">
{% thumbnail object.image "200x200" crop="center" as tn %}
<a href="{{ object.get_absolute_url }}" class="d-block" title="{{ object.name }}">
<img
src="{{ tn.url }}"
width="{{ tn.width }}"
height="{{ tn.height }}"
class="rounded"
alt="{{ object.description|default:object.name }}"
/>
</a>
{% endthumbnail %}
<div class="text-center text-secondary text-truncate fs-5">
{{ object }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
{% blocktrans with object_type=object|meta:"verbose_name" %}
No images have been attached to this {{ object_type }}.
{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -33,6 +33,7 @@ requests==2.32.4
rq==2.4.1
social-auth-app-django==5.5.1
social-auth-core==4.7.0
sorl-thumbnail==12.11.0
strawberry-graphql==0.278.0
strawberry-graphql-django==0.65.1
svgwrite==1.4.3