├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── demo ├── __init__.py ├── manage.py ├── settings.py ├── test_requirements.txt ├── urls.py └── wsgi.py ├── djfiles ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── tests │ ├── __init__.py │ ├── test_admin_form.py │ ├── test_models.py │ └── test_views.py ├── urls.py └── views.py ├── requirements └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | install: 8 | - pip install -r demo/test_requirements.txt 9 | - pip install coveralls 10 | - python setup.py develop 11 | script: 12 | - cd demo 13 | - coverage run --source='../djfiles' manage.py test djfiles 14 | after_success: 15 | - coveralls -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kirill Bobrov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Coverage Status](https://coveralls.io/repos/github/luminousmen/djfiles/badge.svg?branch=master)](https://coveralls.io/github/luminousmen/djfiles?branch=master) 2 | [![Build Status](https://travis-ci.org/luminousmen/djfiles.svg?branch=master)](https://travis-ci.org/luminousmen/djfiles) 3 | [![PyPI version](https://badge.fury.io/py/django-djfiles.svg)](https://badge.fury.io/py/django-djfiles) 4 | [![licence](https://camo.githubusercontent.com/bcd5e9b1f7f3f648ca97add1262d43b0e31d25ec/687474703a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4253442d627269676874677265656e2e737667)](https://github.com/luminousmen/djfiles/blob/master/LICENCE) 5 | 6 | DJFiles 7 | ===== 8 | 9 | DJFiles is a simple Django app for manage static files of your project in admin panel. This app is useful for projects where there is a content manager, which don't have access to server via ssh or don't even know what it is =) 10 | 11 | Using this app you will be able to save static files through admin panel. Files will be saved in /media/ directory of your project. It's useful if you are not always have access to server or have a content manager who responsible for managing such files. 12 | 13 | ### Requirements 14 | 15 | * Python >= 2.7 16 | * Django >= 1.8 17 | * python-slugify 18 | 19 | ### Installation 20 | 21 | ```bash 22 | $ pip install django-djfiles 23 | ``` 24 | 25 | Add ```djfiles``` to your INSTALLED_APPS setting like this: 26 | 27 | ``` 28 | INSTALLED_APPS = [ 29 | ... 30 | 31 | 'djfiles', 32 | ] 33 | ``` 34 | 35 | Apply ```djfiles``` migrations: 36 | 37 | ```bash 38 | $ ./manage.py migrate djfiles 39 | ``` 40 | 41 | Add urls to your project urls so you can get image by slug: 42 | 43 | ```bash 44 | urlpatterns += url(r'^djfiles/', include('djfiles.urls')) 45 | ``` 46 | -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luminousmen/djfiles/9d1e2d7fe36824e36660e7b8c0d00ed0d44d3243/demo/__init__.py -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /demo/settings.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | SECRET_KEY = 'test' 7 | 8 | DEBUG = True 9 | 10 | ALLOWED_HOSTS = [] 11 | 12 | 13 | # Application definition 14 | 15 | INSTALLED_APPS = [ 16 | 'django.contrib.admin', 17 | 'django.contrib.auth', 18 | 'django.contrib.contenttypes', 19 | 'django.contrib.sessions', 20 | 'django.contrib.messages', 21 | 'django.contrib.staticfiles', 22 | 23 | 'djfiles' 24 | ] 25 | 26 | MIDDLEWARE_CLASSES = [ 27 | 'django.middleware.security.SecurityMiddleware', 28 | 'django.contrib.sessions.middleware.SessionMiddleware', 29 | 'django.middleware.common.CommonMiddleware', 30 | 'django.middleware.csrf.CsrfViewMiddleware', 31 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 32 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 33 | 'django.contrib.messages.middleware.MessageMiddleware', 34 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 35 | ] 36 | 37 | ROOT_URLCONF = 'urls' 38 | 39 | TEMPLATES = [ 40 | { 41 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 42 | 'DIRS': [], 43 | 'APP_DIRS': True, 44 | 'OPTIONS': { 45 | 'context_processors': [ 46 | 'django.template.context_processors.debug', 47 | 'django.template.context_processors.request', 48 | 'django.contrib.auth.context_processors.auth', 49 | 'django.contrib.messages.context_processors.messages', 50 | ], 51 | }, 52 | }, 53 | ] 54 | 55 | WSGI_APPLICATION = 'wsgi.application' 56 | 57 | 58 | DATABASES = { 59 | 'default': { 60 | 'ENGINE': 'django.db.backends.sqlite3', 61 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 62 | } 63 | } 64 | 65 | LANGUAGE_CODE = 'en-us' 66 | TIME_ZONE = 'UTC' 67 | USE_I18N = True 68 | USE_L10N = True 69 | USE_TZ = True 70 | 71 | 72 | STATIC_URL = '/static/' 73 | 74 | # just for demo project 75 | MEDIA_URL = '/files/' -------------------------------------------------------------------------------- /demo/test_requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.9.6 2 | six>=1.10.0 -------------------------------------------------------------------------------- /demo/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django.conf.urls import url, include 3 | from django.contrib import admin 4 | from django.conf import settings 5 | from django.conf.urls.static import static 6 | 7 | 8 | urlpatterns = [ 9 | url(r'^admin/', admin.site.urls), 10 | url(r'^djfiles/', include('djfiles.urls')), 11 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 12 | -------------------------------------------------------------------------------- /demo/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 6 | 7 | application = get_wsgi_application() -------------------------------------------------------------------------------- /djfiles/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'djfiles.apps.FilesConfig' -------------------------------------------------------------------------------- /djfiles/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.contrib import admin 4 | from django import forms 5 | 6 | from .models import File 7 | 8 | 9 | class FileForm(forms.ModelForm): 10 | 11 | def save(self, *a, **kw): 12 | kw.update({'commit': False}) 13 | instance = super(FileForm, self).save(*a, **kw) 14 | 15 | # InMemoryUploadedFile at first save FileField otherwise 16 | data = self.cleaned_data['content'] 17 | instance.mime_type = getattr(data, 'content_type', 18 | instance.mime_type) 19 | 20 | instance.save() 21 | return instance 22 | 23 | class Meta: 24 | model = File 25 | fields = '__all__' 26 | 27 | 28 | @admin.register(File) 29 | class FileAdmin(admin.ModelAdmin): 30 | 31 | list_display = ('slug', 'url', 'description') 32 | readonly_fields = ('url', 'preview', 'mime_type') 33 | search_fields = ('slug',) 34 | form = FileForm 35 | -------------------------------------------------------------------------------- /djfiles/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class FilesConfig(AppConfig): 7 | name = u'djfiles' 8 | verbose_name = u'DJFiles' 9 | -------------------------------------------------------------------------------- /djfiles/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.6 on 2016-05-08 19:12 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import djfiles.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='File', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('slug', models.SlugField(unique=True)), 22 | ('mime_type', models.CharField(blank=True, max_length=128, null=True)), 23 | ('content', models.FileField(upload_to=djfiles.models.file_upload_to)), 24 | ('description', models.TextField(blank=True, null=True)), 25 | ], 26 | options={ 27 | 'db_table': 'files', 28 | 'verbose_name': '\u0444\u0430\u0439\u043b', 29 | 'verbose_name_plural': '\u0444\u0430\u0439\u043b\u044b', 30 | }, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /djfiles/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luminousmen/djfiles/9d1e2d7fe36824e36660e7b8c0d00ed0d44d3243/djfiles/migrations/__init__.py -------------------------------------------------------------------------------- /djfiles/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | from slugify import slugify 6 | 7 | from django.db import models 8 | from django.db.models.signals import pre_delete, pre_save 9 | 10 | 11 | def on_delete(sender, instance, **kwargs): 12 | for field in instance._meta.get_fields(): 13 | if field and isinstance(field, models.FileField): 14 | fieldfield = getattr(instance, field.name) 15 | fieldfield.delete(False) 16 | 17 | 18 | def on_save(sender, instance, **kwargs): 19 | if not instance.id: 20 | return 21 | 22 | if getattr(instance, '_pre_save_called_times', False): 23 | initial = sender.objects.get(pk=instance.id) 24 | 25 | for field in instance._meta.get_fields(): 26 | if field and isinstance(field, models.FileField): 27 | new_value = getattr(instance, field.name) 28 | old_value = getattr(initial, field.name) 29 | if old_value and old_value != new_value: 30 | old_value.delete(False) 31 | 32 | instance._pre_save_called_times = True 33 | 34 | 35 | def cleanup(cls): 36 | uid = cls.__name__ 37 | pre_delete.connect(receiver=on_delete, sender=cls, dispatch_uid=uid) 38 | pre_save.connect(receiver=on_save, sender=cls, dispatch_uid=uid) 39 | return cls 40 | 41 | 42 | def file_upload_to(instance, filename): 43 | name, ext = os.path.splitext(filename) 44 | filename = '{}{}'.format(slugify(name), ext) 45 | return os.path.join('files', filename) 46 | 47 | 48 | @cleanup 49 | class File(models.Model): 50 | 51 | slug = models.SlugField(unique=True) 52 | mime_type = models.CharField( 53 | max_length=128, null=True, blank=True) 54 | content = models.FileField(upload_to=file_upload_to) 55 | description = models.TextField(null=True, blank=True) 56 | 57 | def __unicode__(self): 58 | return self.slug 59 | 60 | def preview(self): 61 | 62 | if self.mime_type: 63 | if self.mime_type.startswith('image/'): 64 | preview = u'' 65 | return preview.format(self.content.url) 66 | return self.mime_type 67 | return '-' 68 | 69 | preview.allow_tags = True 70 | 71 | def url(self): 72 | return u'{0}'.format(self.content.url) 73 | url.allow_tags = True 74 | 75 | class Meta: 76 | db_table = 'files' 77 | verbose_name = 'file' 78 | verbose_name_plural = 'files' 79 | -------------------------------------------------------------------------------- /djfiles/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luminousmen/djfiles/9d1e2d7fe36824e36660e7b8c0d00ed0d44d3243/djfiles/tests/__init__.py -------------------------------------------------------------------------------- /djfiles/tests/test_admin_form.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | import os 5 | 6 | from django.test import TestCase 7 | from django.db.models import signals 8 | from django.core.files.uploadedfile import SimpleUploadedFile 9 | 10 | from ..admin import FileAdmin, FileForm 11 | from ..models import File, on_save, on_delete 12 | 13 | 14 | class ModelAdminTests(TestCase): 15 | 16 | def setUp(self): 17 | # creating empty file 18 | self.f = open("test.txt", 'wb') 19 | self.f.write(str("file_content").encode('utf-8')) 20 | self.f = open("test.txt", 'rb') 21 | 22 | def test_validate_empty_form(self): 23 | form = FileForm() 24 | 25 | self.assertFalse(form.is_valid()) 26 | 27 | def test_validate_full_form(self): 28 | uploadedfile = SimpleUploadedFile("test.txt", self.f.read()) 29 | form = FileForm({'slug': 'test'}, {'content': uploadedfile}) 30 | 31 | self.assertTrue(form.is_valid()) 32 | 33 | def test_save_form(self): 34 | # save valid admin form 35 | uploadedfile = SimpleUploadedFile("test.txt", self.f.read()) 36 | form = FileForm({'slug': 'test'}, {'content': uploadedfile}) 37 | self.obj = form.save() 38 | 39 | self.assertEqual('text/plain', self.obj.mime_type) 40 | self.assertEqual(u'test', self.obj.slug) 41 | self.assertNotEqual(None, self.obj.content) 42 | 43 | self.obj.delete() 44 | 45 | def test_save_signal(self): 46 | signals.pre_save.connect(on_save, weak=False) 47 | 48 | try: 49 | uploadedfile = SimpleUploadedFile("test.txt", self.f.read()) 50 | f1 = File.objects.create(slug="test", content=uploadedfile) 51 | 52 | with self.assertRaises(AttributeError): 53 | # if instance wasn't saved then there is no attribute '_pre_save_called_times' 54 | f1._pre_save_called_times 55 | 56 | # save path to delete file after test 57 | path = f1.content.path 58 | 59 | f1.save() 60 | self.assertEqual(True, os.path.isfile(path)) 61 | self.assertEqual(True, f1._pre_save_called_times) 62 | 63 | finally: 64 | f1.delete() 65 | signals.pre_save.disconnect(on_save) 66 | 67 | def test_delete_signal(self): 68 | signals.pre_delete.connect(on_delete, weak=False) 69 | 70 | try: 71 | uploadedfile = SimpleUploadedFile("test.txt", self.f.read()) 72 | f1 = File.objects.create(slug="test", content=uploadedfile) 73 | path = f1.content.path 74 | f1.delete() 75 | 76 | # file not exist after delete 77 | self.assertEqual(False, os.path.isfile(path)) 78 | 79 | finally: 80 | signals.pre_delete.disconnect(on_delete) 81 | 82 | def tearDown(self): 83 | import os 84 | os.remove("test.txt") 85 | -------------------------------------------------------------------------------- /djfiles/tests/test_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.test import TestCase 4 | 5 | from ..models import File 6 | 7 | 8 | class ModelTestCase(TestCase): 9 | 10 | def setUp(self): 11 | self.no_slug_file = File.objects.create(slug='test') 12 | 13 | def test_model_name(self): 14 | self.assertEqual(self.no_slug_file.__class__.__name__, 'File') 15 | 16 | def test_save_file(self): 17 | self.no_slug_file.save() 18 | f = File.objects.get(pk=1) 19 | self.assertEqual(f.slug, 'test') 20 | 21 | def tearDown(self): 22 | self.no_slug_file.delete() 23 | -------------------------------------------------------------------------------- /djfiles/tests/test_views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.test import TestCase 4 | from django.core.files.uploadedfile import SimpleUploadedFile 5 | 6 | from ..models import File 7 | from ..views import get_file 8 | 9 | 10 | class ViewTestCase(TestCase): 11 | 12 | def setUp(self): 13 | self.no_file = File(slug='no_file') 14 | self.no_file.save() 15 | 16 | self.f = open("test.txt", 'wb') 17 | self.f.write(str("file_content").encode('utf-8')) 18 | self.f = open("test.txt", 'rb') 19 | 20 | f = SimpleUploadedFile("test.txt", self.f.read()) 21 | self.with_file = File(slug='with_file', content=f) 22 | self.with_file.save() 23 | 24 | def test_no_file(self): 25 | # no file assosiated with instance 26 | self.assertRaises(ValueError, get_file, None, 'no_file') 27 | 28 | def test_with_file(self): 29 | # test getting not empty file -> redirect 30 | response = get_file(None, 'with_file') 31 | self.assertEqual(response.status_code, 302) 32 | 33 | def tearDown(self): 34 | self.no_file.delete() 35 | self.with_file.delete() 36 | -------------------------------------------------------------------------------- /djfiles/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^(?P\w+)/$', views.get_file), 7 | ] 8 | -------------------------------------------------------------------------------- /djfiles/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.shortcuts import redirect, get_object_or_404 4 | 5 | from .models import File 6 | 7 | 8 | def get_file(request, filename): 9 | 10 | f = get_object_or_404(File, slug=filename) 11 | return redirect(f.content.url) 12 | -------------------------------------------------------------------------------- /requirements: -------------------------------------------------------------------------------- 1 | Django>=1.9.6 2 | git+https://github.com/luminousmen/djfiles#egg=django_djfiles 3 | pypandoc==1.1.3 4 | unicode-slugify==0.1.3 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import io 5 | from setuptools import setup, find_packages 6 | 7 | 8 | with io.open('README.md', encoding='utf-8') as f: 9 | long_description = f.read() 10 | try: 11 | import pypandoc 12 | long_description = pypandoc.convert(long_description, 'rst', 'md') 13 | long_description = long_description.replace('\r', '') 14 | with io.open('README.rst', mode='w', encoding='utf-8') as f: 15 | f.write(long_description) 16 | except (ImportError, OSError): 17 | print("!!! Can't convert README.md - install pandoc and/or pypandoc.") 18 | 19 | 20 | # allow setup.py to be run from any path 21 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 22 | 23 | setup( 24 | name='django-djfiles', 25 | version='0.2', 26 | packages=find_packages(exclude=['djfiles.tests']), 27 | include_package_data=True, 28 | license='BSD License', 29 | description='A simple Django app to upload files via admin panel.', 30 | long_description=long_description, 31 | url='https://github.com/luminousmen/djfiles', 32 | author='Bobrov Kirill', 33 | author_email='miaplanedo@gmail.com', 34 | classifiers=[ 35 | 'Framework :: Django', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: BSD License', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 2', 41 | 'Programming Language :: Python :: 3', 42 | 'Programming Language :: Python :: 3.4', 43 | 'Programming Language :: Python :: 3.5', 44 | 'Topic :: Internet :: WWW/HTTP', 45 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 46 | ], 47 | install_requires=[ 48 | 'python-slugify', 49 | ], 50 | ) 51 | --------------------------------------------------------------------------------