Package: drf-attachments
DRF AttachmentsDjango module to manage any model's file up-/downloads by relating an Attachment model to it.The module can be used for extending the Django Admin or for exposing the attachment via Django REST Framework (DRF).
If used with DRF,django-filteris an additional requirement.Installation and SetupInstall using pip:pipinstalldrf-attachmentsIntegratedrf_attachmentsinto yoursettings.pyINSTALLED_APPS=[# ...'drf_attachments',# ...]Configure attachment settings# Attachments ConfigurationATTACHMENT_MAX_UPLOAD_SIZE=1024*1024*25# Custom context choices to distinguish attachment types# (must start with `ATTACHMENT_CONTEXT_` to be found automatically)# Examples contexts: Drivers License Front, Drivers License BackATTACHMENT_CONTEXT_FOO="foo"ATTACHMENT_CONTEXT_BAR="bar"# ...# Default in case no context is givenATTACHMENT_DEFAULT_CONTEXT="attachment"# Directory where attachments will be stored by defaultPRIVATE_ROOT="media/private"# Permissions that should be created for all models (optional)GLOBAL_MODEL_PERMISSIONS=[]Usage with Django AdminAddAttachmentInlineAdminorReadOnlyAttachmentInlineAdminto each model that needs attachmentsfromdjango.contribimportadminfromdrf_attachments.adminimportAttachmentInlineAdminfromtestapp.modelsimportPhotoAlbum@admin.register(PhotoAlbum)classPhotoAlbumAdmin(admin.ModelAdmin):inlines=[AttachmentInlineAdmin,]ReadOnlyAttachmentInlineAdminis useful when attachments should be provided only by REST API. You may consider
extending the classes in order to handle additional permission checks.Usage with DRF (ToDo: API needs to be simplified)Add a helper/utils file to your project's source code (e.g.attachments.py) and prepare the methodsattachment_content_object_field,attachment_context_translations,filter_viewable_content_types,filter_editable_content_typesandfilter_deletable_content_typesthere.# within app/your_app_name/attachments.pydefattachment_content_object_field():"""Manually define all relations using a GenericRelatedField to Attachment (teach the package how to map the relation)"""passdefattachment_context_translations():"""Manually define context type translations(defined in settings.py via "ATTACHMENT_CONTEXT_x" and "ATTACHMENT_DEFAULT_CONTEXT""""return{}deffilter_viewable_content_types(queryset):"""Override to return viewable related content_types."""returnquerysetdeffilter_editable_content_types(queryset):"""Override to return editable related content_types."""returnquerysetdeffilter_deletable_content_types(queryset):"""Override to return deletable related content_types."""returnquerysetDefine the helper/utils methods' paths within yoursettings.pyasATTACHMENT_CONTENT_OBJECT_FIELD_CALLABLEandATTACHMENT_CONTEXT_TRANSLATIONS_CALLABLE:# within settings.pyATTACHMENT_CONTENT_OBJECT_FIELD_CALLABLE="your_app_name.attachments.attachment_content_object_field"ATTACHMENT_CONTEXT_TRANSLATIONS_CALLABLE="your_app_name.attachments.attachment_context_translations"Add all possible context choices (and the default value, if defined) to theattachment_context_translationsmethod to make them detectable via themakemessagescommand, e.g.:# within app/your_app_name/attachments.pyfromdjango.utils.translationimportgettext_lazyas_fromdjango.confimportsettingsdefattachment_context_translations():"""Manually define all context type translations(defined in settings.py via "ATTACHMENT_CONTEXT_x" and "ATTACHMENT_DEFAULT_CONTEXT""""return{settings.ATTACHMENT_CONTEXT_DRIVERS_LICENSE:_("Driver's license"),settings.ATTACHMENT_CONTEXT_OFFER:_("Offer"),settings.ATTACHMENT_CONTEXT_CONTRACT:_("Contract"),settings.ATTACHMENT_CONTEXT_OTHER:_("Other"),settings.ATTACHMENT_DEFAULT_CONTEXT:_("Attachment"),}UsageAttachments accept any other Model as content_object and store the uploaded files in their respective directories
(if not defined otherwise in attachment_upload_path)To manage file uploads for any existing model you must create a one-to-many "attachments" relation to it, via following these steps:Add a generic relation in the model class that is supposed to manage file uploads, e.g. users.UserVehicle:# within app/your_app_name/users/models/models.py UserVehicle classfromdrf_attachments.models.fieldsimportAttachmentRelation# NOTE: since Attachment.object_id is of type CharField, filters for user-related attachments will need to look for its content_type and pk, e.g.:# user_vehicle_attachments = AttachmentQuerySet.filter(content_type=user_vehicle_content_type, object_id=user_vehicle_pk)attachments=AttachmentRelation()Add the AttachmentMeta class with the relevant restrictions to the newly referenced model class (e.g. users.UserVehicle). If not defined otherwise, the default settings will be used for validation:# within app/your_app_name/users/models/models.py UserVehicle classfromdjango.confimportsettingsclassAttachmentMeta:valid_mime_types=[]# allow all mime typesvalid_extensions=[]# allow all extensionsmin_size=0# no min size requiredmax_size=settings.ATTACHMENT_MAX_UPLOAD_SIZE# default and max (higher max_size values will be ignored)unique_upload=False# if set to True, the related model will only have one Attachment at a time (when adding any further Attachments, previous ones will be deleted permanently); unique_upload=True trumps unique_upload_per_context=True, so with unique_upload=True the unique_upload_per_context config will be ignoredunique_upload_per_context=False# if set to True, the related model will only have one Attachment per context at a time (when adding any further Attachments, previous ones with the same context will be deleted permanently); unique_upload=True trumps unique_upload_per_context=True, so if you want this config, make sure to have unique_upload=FalseE.g. in users.UserVehicle model class to allow only a single Attachment (driver's license) that must be an image
(jpg/png):# within app/your_app_name/users/models/models.py UserVehicle classclassAttachmentMeta:valid_mime_types=['image/jpeg','image/png']valid_extensions=['.jpg','.jpeg','.jpe','.png']unique_upload=TrueAdd the newly referenced model (e.g. users.UserVehicle) as HyperlinkedRelatedField to the helper/util file'sattachment_content_object_fieldmethod, e.g.:# within app/your_app_name/attachments.pyfromgeneric_relations.relationsimportGenericRelatedFieldfromrest_frameworkimportserializersfromtestapp.modelsimportPhotoAlbumdefattachment_content_object_field():"""Manually define all relations using a GenericRelatedField to Attachment (teach the package how to map the relation)"""returnGenericRelatedField({PhotoAlbum:serializers.HyperlinkedRelatedField(queryset=PhotoAlbum.objects.all(),view_name='photoalbum-detail',),# ...})Optional: Add the newly referenced model (e.g. testapp.PhotoAlbum) as OR-filter to any relevant queryset filter method within the helper/utils file, e.g.:# within app/your_app_name/attachments.pyfromdjango.contrib.contenttypes.modelsimportContentTypefromdjango.db.modelsimportQfromdjango_userforeignkey.requestimportget_current_userfromtestapp.modelsimportPhotoAlbumdeffilter_viewable_content_types(queryset):"""Return only attachments related to PhotoAlbums belonging to the currently logged-in user."""user=get_current_user()content_type=ContentType.objects.get_for_model(PhotoAlbum)viewable_ids=list(PhotoAlbum.objects.filter(user=user).values_list('pk',flat=True))queryset=queryset.filter(Q(content_type=content_type,object_id__in=viewable_ids,# user's own attachments),)returnquerysetdeffilter_editable_content_types(queryset):"""No attachments are editable"""returnqueryset.none()deffilter_deletable_content_types(queryset):"""Attachments are only deletable for admin (superuser)"""user=get_current_user()ifuser.is_superuser:returnquerysetreturnqueryset.none()Add attachment DRF route# app/your_app_name/within urls.pyfromdrf_attachments.rest.viewsimportAttachmentViewSetfromrest_frameworkimportroutersrouter=routers.DefaultRouter()router.register(r"attachment",AttachmentViewSet)Storage settingsChange the directory where attachments will be stored by setting thestorage_locationinAttachmentMetawithin the model class:fromdjango.confimportsettingsclassAttachmentMeta:storage_location='path/to/another/directory'# default is settings.PRIVATE_ROOTAuto-formatter setupWe use isort (https://github.com/pycqa/isort) and black (https://github.com/psf/black) for local auto-formatting and for linting in the CI pipeline.
The pre-commit framework (https://pre-commit.com) provides GIT hooks for these tools, so they are automatically applied before every commit.Steps to activate:Install the pre-commit framework:pip install pre-commit(for alternative installation options seehttps://pre-commit.com/#install)Activate the framework (from the root directory of the repository):pre-commit installHint: You can also run the formatters manually at any time with the following command:pre-commit run --all-filesDownload endpointBy default, a generic download endpoint is provided to authenticated users, e.g.http://0.0.0.0:8000/api/attachment/5b948d37-dcfb-4e54-998c-5add35701c53/download/.You can customize the authentication requirements e.g. by subclassingAttachmentViewSetand providing custompermission_classes.If you use a customAttachmentViewSet, make sure that there still is a registeredattachment-downloadURL.
This URL is used by thedownload_urlproperty in the API and the download link in the admin panel.TestApp Setupcdtests
pythonmanage.pymigrate
pythonmanage.pycreatesuperuser# ... enter credentials ...pythonmanage.pyrunserver# The app should now be served on http://localhost:8000# Browsable API: http://localhost:8000/api# Admin Panel: http://localhost:8000/adminUnit TestsSee foldertests/. Basically, all endpoints are covered with multiple
unit tests.Follow below instructions to run the tests.
You may exchange the installed Django and DRF versions according to your requirements.
:warning: Depending on your local environment settings you might need to explicitly callpython3instead ofpython.# install dependenciespython-mpipinstall--upgradepip
pipinstall-rrequirements.txt# setup environmentpipinstall-e.# run testscdtests&&pythonmanage.pytestToDosSimplify configuration (maybe a default configuration class that can be subclasses for customizations?)Remove settings.GLOBAL_MODEL_PERMISSIONS handling (not really related to attachments)?Make context optional or remove default context (currently a context is required byChoiceFieldin the serializer)Integrate pre-commit in github actionsRemove dot fromvalid_extensionsdefinition / make optionalChangeAttachment.get_*methods to dynamic propertiesAllow different attachment configurations per model (e.g. MyModel.photos and MyModel.docs with different constraints)