├── tests ├── __init__.py ├── media │ └── adminfiles │ │ ├── somefile.txt │ │ └── tiny.png ├── templates │ └── alt │ │ ├── image │ │ └── png.html │ │ └── default.html ├── models.py ├── urls.py ├── runtests.py ├── test_settings.py └── tests.py ├── adminfiles ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── adminfiles_browser_views.py │ │ └── sync_upload_refs.py ├── templatetags │ ├── __init__.py │ └── adminfiles_tags.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── static │ └── adminfiles │ │ ├── icon_addlink.gif │ │ ├── mimetypes │ │ ├── doc.png │ │ ├── pdf.png │ │ ├── xls.png │ │ ├── zip.png │ │ └── empty.png │ │ ├── icon_refreshlink.gif │ │ ├── model.js │ │ ├── photo-edit.js │ │ ├── adminfiles.css │ │ └── adminfiles.js ├── templates │ └── adminfiles │ │ ├── render │ │ ├── default.html │ │ └── image │ │ │ └── default.html │ │ └── uploader │ │ ├── video.html │ │ ├── flickr.html │ │ └── base.html ├── urls.py ├── settings.py ├── parse.py ├── admin.py ├── listeners.py ├── utils.py ├── models.py ├── views.py └── flickr.py ├── test_project ├── __init__.py ├── testapp │ ├── __init__.py │ ├── models.py │ └── admin.py ├── requirements.txt ├── run_test_project.sh ├── manage.py ├── urls.py ├── README.txt ├── fixtures │ └── initial_data.json └── settings.py ├── .gitignore ├── .hgignore ├── AUTHORS.rst ├── MANIFEST.in ├── README.transifex ├── TODO.rst ├── .hgtags ├── tox.ini ├── LICENSE.txt ├── setup.py ├── CHANGES.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adminfiles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adminfiles/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_project/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adminfiles/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/media/adminfiles/somefile.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /adminfiles/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/alt/image/png.html: -------------------------------------------------------------------------------- 1 | {{ upload }} 2 | -------------------------------------------------------------------------------- /tests/templates/alt/default.html: -------------------------------------------------------------------------------- 1 | {{ upload }} 2 | {% if options.class %}class="{{ options.class }}"{% endif %} 3 | -------------------------------------------------------------------------------- /tests/media/adminfiles/tiny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/tests/media/adminfiles/tiny.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | django_adminfiles.egg-info/* 3 | HGREV 4 | TAGS 5 | tests/media/adminfiles/*_q 6 | .tox/* 7 | *.un~ 8 | *.pyc 9 | -------------------------------------------------------------------------------- /adminfiles/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /adminfiles/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /adminfiles/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /adminfiles/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/icon_addlink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/static/adminfiles/icon_addlink.gif -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/mimetypes/doc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/static/adminfiles/mimetypes/doc.png -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/mimetypes/pdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/static/adminfiles/mimetypes/pdf.png -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/mimetypes/xls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/static/adminfiles/mimetypes/xls.png -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/mimetypes/zip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/static/adminfiles/mimetypes/zip.png -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/icon_refreshlink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/static/adminfiles/icon_refreshlink.gif -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/mimetypes/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carljm/django-adminfiles/HEAD/adminfiles/static/adminfiles/mimetypes/empty.png -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | ^dist/ 2 | ^build/ 3 | ^django_adminfiles.egg-info/ 4 | ^HGREV$ 5 | ^TAGS$ 6 | ^tests/media/adminfiles/.*_q 7 | ^tests/media/cache/.* 8 | ^.tox/ 9 | ^.*.pyc$ 10 | -------------------------------------------------------------------------------- /test_project/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .. 2 | 3 | Django==1.5.1 4 | PIL==1.1.7 5 | djangoembed==0.1.1 6 | BeautifulSoup==3.2.1 7 | httplib2==0.8 8 | simplejson==3.3.0 9 | sorl-thumbnail==11.12 10 | wsgiref==0.1.2 11 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class Post(models.Model): 4 | title = models.CharField(max_length=50) 5 | content = models.TextField() 6 | 7 | def __unicode__(self): 8 | return self.title 9 | -------------------------------------------------------------------------------- /adminfiles/templates/adminfiles/render/default.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | {% if options.title %}{{ options.title }}{% else %}{{ upload.title }}{% endif %} 4 | 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /test_project/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class Article(models.Model): 4 | title = models.CharField(max_length=100) 5 | content = models.TextField() 6 | 7 | def __unicode__(self): 8 | return self.title 9 | -------------------------------------------------------------------------------- /test_project/run_test_project.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ./manage.py syncdb --noinput 4 | pushd static 5 | ln -s ../../adminfiles/static/adminfiles . 6 | popd 7 | ./manage.py runserver 8 | rm adminfiles-test.db 9 | rm static/adminfiles 10 | rm -rf media/* 11 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url, include 2 | 3 | from django.contrib import admin 4 | 5 | urlpatterns = patterns('', 6 | url(r'^adminfiles/', include('adminfiles.urls')), 7 | url(r'^admin/', include(admin.site.urls)) 8 | ) 9 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Ales Zabala Alava 2 | Andrés Reyes Monge 3 | Carl Meyer 4 | Jannis Leidel 5 | Ludwik Trammer 6 | Rudolph Froger 7 | sgt.hulka@gmail.com 8 | Svyatoslav Bulbakha 9 | Tuk Bredsdorff 10 | Unai Zalakain 11 | vitaly4uk 12 | -------------------------------------------------------------------------------- /adminfiles/templates/adminfiles/render/image/default.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% if options.alt %}{{ options.alt }}{% else %}{{ upload.title }}{% endif %} 3 | {% endspaceless %} 4 | -------------------------------------------------------------------------------- /test_project/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 | -------------------------------------------------------------------------------- /test_project/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from adminfiles.admin import FilePickerAdmin 4 | 5 | from test_project.testapp.models import Article 6 | 7 | class ArticleAdmin(FilePickerAdmin): 8 | adminfiles_fields = ['content'] 9 | 10 | admin.site.register(Article, ArticleAdmin) 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CHANGES.rst 3 | include LICENSE.txt 4 | include MANIFEST.in 5 | include README.rst 6 | include TODO.rst 7 | include HGREV 8 | recursive-include adminfiles/static/adminfiles *.png *.css *.js *.gif 9 | recursive-include adminfiles/templates/adminfiles *.html 10 | recursive-include adminfiles/locale *.mo *.po 11 | -------------------------------------------------------------------------------- /README.transifex: -------------------------------------------------------------------------------- 1 | Transifex.net Token Verification 2 | ================================= 3 | 4 | The list of tokens bellow guarantee the respective users to be able to enable 5 | submission on components using the following repository url: 6 | 7 | http://bitbucket.org/carljm/django-adminfiles 8 | 9 | Tokens: 10 | 11 | S3sdtzMgNcF8d7fzUxf2Tjk6VmrhaPfK / carljm 12 | -------------------------------------------------------------------------------- /test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url, include 2 | from django.conf import settings 3 | from django.conf.urls.static import static 4 | 5 | from django.contrib import admin 6 | admin.autodiscover() 7 | 8 | urlpatterns = patterns('', 9 | url(r'', include(admin.site.urls)), 10 | url(r'^adminfiles/', include('adminfiles.urls')), 11 | ) + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 12 | -------------------------------------------------------------------------------- /TODO.rst: -------------------------------------------------------------------------------- 1 | TODO 2 | ==== 3 | 4 | - Fix run_tests and short DB names deprecation warnings when running tests 5 | on 1.3. 6 | 7 | - Test and document snipshot integration and select dropdown integration. 8 | 9 | - Add integration with plain FilePathField or FileField (for unobtrusive use 10 | with third-party apps). 11 | 12 | - Make URLs in JS portable (by reversing "all" view to find out where 13 | we've been included in the URLconf). 14 | 15 | - Link to online demo or video. 16 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | ffcb46d31e31691a65c9cf8f92914a997c63b92b 0.2.0 2 | 1d7bc47b4f596cae7980eb06c336ab06aee34d15 0.3.0 3 | 085b6b72841d23c78c0497f5a3bc0fc3a1dd95c5 0.3.1 4 | 20611620567229c07c44c4dd50fc574f614fb07f 0.3.2 5 | 2d54ce3808587618bb5531c585ca272455e6cc82 0.3.3 6 | 6a21fe519d1f60a51b02d681cfbdb18257be50eb 0.3.4 7 | 9b7e508476bba3c1109614337cb4e9cdcf864af3 0.5.0 8 | eb989efd512c7f3b9f2d31c1e924ec407fbf1598 0.5.1 9 | ba2532785d4263964eddaa39db90a8bb5fd4bf74 1.0 10 | 293471a8147eba32ff994c1308a1989e58601035 1.0.1 11 | -------------------------------------------------------------------------------- /test_project/README.txt: -------------------------------------------------------------------------------- 1 | A bare-bones Django project for live-testing django-adminfiles; particularly 2 | Javascript functionality that can't easily be unit-tested. 3 | 4 | Run ``pip install -r requirements.txt`` to install all the dependencies you 5 | need into a virtualenv; then run ``./run_test_project.sh`` to set up a test 6 | database and run the test server on ``http://localhost:8000``. 7 | 8 | Use admin/admin to log into the admin of the test server. 9 | 10 | Ideally we'd have Selenium tests for this stuff. 11 | -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/model.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | $(function(){ 3 | $('.adminfilespicker').each( 4 | function(){ 5 | var href = '/adminfiles/all/?field='+this.id; 6 | if (this.options) { 7 | $(this).siblings('a.add-another').remove(); 8 | href += '&field_type=select'; 9 | } 10 | $(this).after(''); 11 | }); 12 | }); 13 | })(jQuery); 14 | -------------------------------------------------------------------------------- /tests/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os, sys 4 | 5 | parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | sys.path.insert(0, parent) 7 | 8 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 9 | 10 | def runtests(): 11 | from django.test.simple import DjangoTestSuiteRunner 12 | runner = DjangoTestSuiteRunner( 13 | verbosity=1, interactive=True, failfast=False) 14 | failures = runner.run_tests(['tests']) 15 | sys.exit(failures) 16 | 17 | if __name__ == '__main__': 18 | runtests() 19 | -------------------------------------------------------------------------------- /adminfiles/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | 3 | from django.contrib.admin.views.decorators import staff_member_required 4 | 5 | from adminfiles.views import download, get_enabled_browsers 6 | 7 | urlpatterns = patterns('', 8 | url(r'download/$', staff_member_required(download), 9 | name="adminfiles_download") 10 | ) 11 | 12 | for browser in get_enabled_browsers(): 13 | slug = browser.slug() 14 | urlpatterns += patterns('', 15 | url('%s/$' % slug, browser.as_view(), 16 | name='adminfiles_%s' % slug)) 17 | -------------------------------------------------------------------------------- /adminfiles/templates/adminfiles/uploader/video.html: -------------------------------------------------------------------------------- 1 | {% extends "adminfiles/uploader/base.html" %} 2 | {% load i18n %} 3 | {% block files %} 4 | {% for v in videos %} 5 |
  • 6 | 12 |
    {{ v.title }}
    13 | {{ v.upload_date }}
    14 |
    15 |
  • 16 | {% endfor %} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /test_project/fixtures/initial_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "auth.user", 5 | "fields": { 6 | "username": "admin", 7 | "first_name": "", 8 | "last_name": "", 9 | "is_active": true, 10 | "is_superuser": true, 11 | "is_staff": true, 12 | "last_login": "2010-04-28 00:07:07", 13 | "groups": [], 14 | "user_permissions": [], 15 | "password": "sha1$52a46$4677c471a12ce2b2320a94adc0846506293b5afd", 16 | "email": "carl@oddbird.net", 17 | "date_joined": "2010-04-28 00:07:07" 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, join 2 | TEST_ROOT = dirname(__file__) 3 | 4 | INSTALLED_APPS = ('adminfiles', 'tests', 5 | 'django.contrib.contenttypes', 6 | 'django.contrib.admin', 7 | 'django.contrib.sites', 8 | 'django.contrib.auth', 9 | 'django.contrib.sessions', 10 | 'sorl.thumbnail') 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | } 15 | } 16 | 17 | SITE_ID = 1 18 | 19 | MEDIA_URL = '/media/' 20 | MEDIA_ROOT = join(TEST_ROOT, 'media') 21 | 22 | STATIC_URL = '/static/' 23 | STATIC_ROOT = MEDIA_ROOT 24 | 25 | ROOT_URLCONF = 'tests.urls' 26 | 27 | TEMPLATE_DIRS = (join(TEST_ROOT, 'templates'),) 28 | 29 | SECRET_KEY = 'not empty' 30 | -------------------------------------------------------------------------------- /adminfiles/templates/adminfiles/uploader/flickr.html: -------------------------------------------------------------------------------- 1 | {% extends "adminfiles/uploader/base.html" %} 2 | {% load i18n %} 3 | {% block files %} 4 | 10 | {% for p in photos %} 11 |
  • 12 | 18 |
    19 | {{ p.title }} 20 |
    21 |
  • 22 | {% endfor %} 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py25-1.4,py26-1.4,py26-trunk,py27-1.4,py27,py27-trunk 3 | 4 | [testenv] 5 | deps= 6 | Django==1.5.1 7 | sorl-thumbnail==11.12 8 | PIL==1.1.7 9 | commands=python setup.py test 10 | 11 | [testenv:py25-1.4] 12 | basepython=python2.5 13 | deps= 14 | Django==1.4.5 15 | sorl-thumbnail==11.12 16 | PIL==1.1.7 17 | 18 | [testenv:py26-1.4] 19 | basepython=python2.6 20 | deps= 21 | Django==1.4.5 22 | sorl-thumbnail==11.12 23 | PIL==1.1.7 24 | 25 | [testenv:py26-trunk] 26 | basepython=python2.6 27 | deps= 28 | https://github.com/django/django/tarball/master 29 | sorl-thumbnail==11.12 30 | PIL==1.1.7 31 | 32 | [testenv:py27-1.4] 33 | basepython=python2.7 34 | deps= 35 | Django==1.4.5 36 | sorl-thumbnail==11.12 37 | PIL==1.1.7 38 | 39 | [testenv:py27-trunk] 40 | basepython=python2.7 41 | deps= 42 | https://github.com/django/django/tarball/master 43 | sorl-thumbnail==11.12 44 | PIL==1.1.7 45 | -------------------------------------------------------------------------------- /test_project/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | BASE = os.path.dirname(os.path.abspath(__file__)) 3 | 4 | DEBUG = True 5 | 6 | SITE_ID = 1 7 | 8 | DATABASES = { 9 | "default": { 10 | "ENGINE": 'django.db.backends.sqlite3', 11 | "NAME":os.path.join(BASE, 'adminfiles-test.db'), 12 | } 13 | } 14 | 15 | STATIC_URL = '/static/' 16 | STATIC_ROOT = os.path.join(BASE, 'static') 17 | MEDIA_ROOT = os.path.join(BASE, 'media') 18 | MEDIA_URL = '/media/' 19 | ADMIN_MEDIA_PREFIX = '/static/admin/' 20 | SECRET_KEY = '6wk#pb((9+oudihdco6m@#1hmr1qp#k+7a=p7c@#z91_^=en-!' 21 | 22 | ROOT_URLCONF = 'test_project.urls' 23 | 24 | FIXTURE_DIRS = [ 25 | os.path.join(BASE, 'fixtures') 26 | ] 27 | 28 | INSTALLED_APPS = ( 29 | 'django.contrib.auth', 30 | 'django.contrib.contenttypes', 31 | 'django.contrib.sessions', 32 | 'django.contrib.sites', 33 | 'django.contrib.staticfiles', 34 | 'django.contrib.admin', 35 | 'adminfiles', 36 | 'sorl.thumbnail', 37 | 'testapp', 38 | 'oembed', 39 | ) 40 | -------------------------------------------------------------------------------- /adminfiles/management/commands/adminfiles_browser_views.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import NoArgsCommand, CommandError 2 | 3 | from adminfiles.settings import ADMINFILES_BROWSER_VIEWS 4 | from adminfiles.views import import_browser, DisableView, BaseView 5 | 6 | class Command(NoArgsCommand): 7 | """ 8 | List all browser views from ADMINFILES_BROWSER_VIEWS and display 9 | whether each one is enabled or disabled, and why. 10 | 11 | """ 12 | def handle_noargs(self, **options): 13 | print "Adminfiles browser views available:" 14 | print 15 | for browser_path in ADMINFILES_BROWSER_VIEWS: 16 | try: 17 | view_class = import_browser(browser_path) 18 | view_class().check() 19 | message = 'enabled' 20 | except (DisableView, ImportError), e: 21 | message = 'disabled (%s)' % e.args[0] 22 | if not issubclass(view_class, BaseView): 23 | message = 'disabled (not subclass of adminfiles.views.BaseView)' 24 | print " * %s: %s" % (browser_path, message) 25 | 26 | -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/photo-edit.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | $('#id_upload').after(' Edit this photo'); 3 | $('#photo-edit').hide(); 4 | var ext = ''; 5 | //check if the upload field contains an image path or PDF 6 | $('#id_upload').change(function(){ 7 | var image_ext = ['.jpg', '.jpeg', '.gif', '.png', '.pdf']; 8 | var found = false; 9 | for (var i=0; i 0) { 11 | found = true; 12 | ext = image_ext[i].replace('.',''); 13 | break; 14 | } 15 | } 16 | if (found == true) 17 | $('#photo-edit').show(); 18 | else 19 | $('#photo-edit').hide(); 20 | }); 21 | //edit form to post to snipshot and submit 22 | $('a#photo-edit').click(function(){ 23 | $(this).after(''); 24 | $(this).after(''); 25 | $(this).parents('form').attr('action', 'http://services.snipshot.com/'); 26 | $(this).parents('form').submit(); 27 | return false; 28 | }); 29 | }); -------------------------------------------------------------------------------- /adminfiles/templatetags/adminfiles_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from adminfiles.parse import parse_options 4 | from adminfiles import utils 5 | 6 | register = template.Library() 7 | 8 | @register.filter 9 | def render_uploads(content, 10 | template_path="adminfiles/render/"): 11 | """ 12 | Render uploaded file references in a content string 13 | (i.e. translate "<<>>" to 'My uploaded file'). 15 | 16 | Just wraps ``adminfiles.utils.render_uploads``. 17 | 18 | """ 19 | return utils.render_uploads(content, template_path) 20 | render_uploads.is_safe = True 21 | 22 | @register.filter 23 | def render_upload(upload, opts_str=''): 24 | """ 25 | Render a single ``FileUpload`` model instance using the 26 | appropriate render template for its mime type. 27 | 28 | Expects options to be in the format "key=val:key2=val2", just like 29 | the embed syntax. Options are parsed into a dictionary and passed 30 | to ``render_upload``. (A ``template_path`` option can be passed 31 | and it will be used as the search path for rendering templates.) 32 | 33 | Just wraps ``adminfiles.utils.render_upload``. 34 | 35 | """ 36 | return utils.render_upload(upload, **parse_options(opts_str)) 37 | render_upload.is_safe = True 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009, Carl Meyer 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /adminfiles/management/commands/sync_upload_refs.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import NoArgsCommand, CommandError 2 | from adminfiles.settings import ADMINFILES_USE_SIGNALS 3 | 4 | from django.contrib import admin 5 | 6 | from adminfiles.models import FileUploadReference 7 | from adminfiles.listeners import referring_models 8 | 9 | class Command(NoArgsCommand): 10 | """ 11 | Delete all ``FileUploadReference`` instances, then re-save all 12 | instances of all models which might contain references to uploaded 13 | files. This ensures that file upload references are in a 14 | consistent state, and renderings of uploads are brought 15 | up-to-date. 16 | 17 | Should only be necessary in unusual circumstances (such as just 18 | after loading a fixture on a different deployment, where 19 | e.g. MEDIA_URL might differ, which would affect the rendering of 20 | links to file uploads). 21 | 22 | Likely to be quite slow if used on a large data set. 23 | 24 | """ 25 | def handle_noargs(self, **options): 26 | if not ADMINFILES_USE_SIGNALS: 27 | raise CommandError('This command has no effect if ' 28 | 'ADMINFILES_USE_SIGNALS setting is False.') 29 | 30 | FileUploadReference.objects.all().delete() 31 | 32 | # apps register themselves as referencing file uploads by 33 | # inheriting their admin options from FilePickerAdmin 34 | admin.autodiscover() 35 | for model in referring_models: 36 | print "Syncing %s" % model.__name__ 37 | for obj in model._default_manager.all(): 38 | obj.save() 39 | -------------------------------------------------------------------------------- /adminfiles/settings.py: -------------------------------------------------------------------------------- 1 | import posixpath 2 | 3 | from django.conf import settings 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | JQUERY_URL = getattr(settings, 'JQUERY_URL', 7 | 'http://ajax.googleapis.com/ajax/libs/jquery/1.4/jquery.min.js') 8 | 9 | if JQUERY_URL and not ((':' in JQUERY_URL) or (JQUERY_URL.startswith('/'))): 10 | JQUERY_URL = posixpath.join(settings.STATIC_URL, JQUERY_URL) 11 | 12 | ADMINFILES_UPLOAD_TO = getattr(settings, 'ADMINFILES_UPLOAD_TO', 'adminfiles') 13 | 14 | ADMINFILES_THUMB_ORDER = getattr(settings, 'ADMINFILES_THUMB_ORDER', 15 | ('-upload_date',)) 16 | 17 | ADMINFILES_USE_SIGNALS = getattr(settings, 'ADMINFILES_USE_SIGNALS', False) 18 | 19 | ADMINFILES_REF_START = getattr(settings, 'ADMINFILES_REF_START', '<<<') 20 | 21 | ADMINFILES_REF_END = getattr(settings, 'ADMINFILES_REF_END', '>>>') 22 | 23 | ADMINFILES_STRING_IF_NOT_FOUND = getattr(settings, 24 | 'ADMINFILES_STRING_IF_NOT_FOUND', 25 | u'') 26 | 27 | ADMINFILES_INSERT_LINKS = getattr( 28 | settings, 29 | 'ADMINFILES_INSERT_LINKS', 30 | {'': [(_('Insert Link'), {})], 31 | 'image': [(_('Insert'), {}), 32 | (_('Insert (align left)'), {'class': 'left'}), 33 | (_('Insert (align right)'), {'class': 'right'})] 34 | }, 35 | ) 36 | 37 | ADMINFILES_STDICON_SET = getattr(settings, 'ADMINFILES_STDICON_SET', None) 38 | 39 | ADMINFILES_BROWSER_VIEWS = getattr(settings, 'ADMINFILES_BROWSER_VIEWS', 40 | ['adminfiles.views.AllView', 41 | 'adminfiles.views.ImagesView', 42 | 'adminfiles.views.AudioView', 43 | 'adminfiles.views.FilesView', 44 | 'adminfiles.views.FlickrView', 45 | 'adminfiles.views.YouTubeView', 46 | 'adminfiles.views.VimeoView']) 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import subprocess 3 | import os.path 4 | 5 | try: 6 | # don't get confused if our sdist is unzipped in a subdir of some 7 | # other hg repo 8 | if os.path.isdir('.hg'): 9 | p = subprocess.Popen(['hg', 'parents', r'--template={rev}\n'], 10 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 11 | if not p.returncode: 12 | fh = open('HGREV', 'w') 13 | fh.write(p.communicate()[0].splitlines()[0]) 14 | fh.close() 15 | except (OSError, IndexError): 16 | pass 17 | 18 | try: 19 | hgrev = open('HGREV').read() 20 | except IOError: 21 | hgrev = '' 22 | 23 | long_description = (open('README.rst').read() + 24 | open('CHANGES.rst').read() + 25 | open('TODO.rst').read()) 26 | 27 | setup( 28 | name='django-adminfiles', 29 | version='1.0.1.post%s' % hgrev, 30 | description='File upload manager and picker for Django admin', 31 | author='Carl Meyer', 32 | author_email='carl@oddbird.net', 33 | long_description=long_description, 34 | url='http://bitbucket.org/carljm/django-adminfiles/', 35 | packages=['adminfiles', 'adminfiles.templatetags', \ 36 | 'adminfiles.management', 'adminfiles.management.commands'], 37 | classifiers=[ 38 | 'Development Status :: 4 - Beta', 39 | 'Environment :: Web Environment', 40 | 'Intended Audience :: Developers', 41 | 'License :: OSI Approved :: BSD License', 42 | 'Operating System :: OS Independent', 43 | 'Programming Language :: Python', 44 | 'Framework :: Django', 45 | ], 46 | zip_safe=False, 47 | test_suite='tests.runtests.runtests', 48 | package_data={'adminfiles': ['static/adminfiles/*.*', 49 | 'static/adminfiles/mimetypes/*.png', 50 | 'templates/adminfiles/render/*.html', 51 | 'templates/adminfiles/render/image/*.html', 52 | 'templates/adminfiles/uploader/*.html', 53 | 'locale/*/LC_MESSAGES/*']} 54 | ) 55 | -------------------------------------------------------------------------------- /adminfiles/parse.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from adminfiles import settings 4 | from adminfiles.models import FileUpload 5 | 6 | # Upload references look like: <<< upload-slug : key=val : key2=val2 >>> 7 | # Spaces are optional, key-val opts are optional, can be any number 8 | # extra indirection is for testability 9 | def _get_upload_re(): 10 | return re.compile(r'%s\s*([\w-]+)((\s*:\s*\w+\s*=\s*.+?)*)\s*%s' 11 | % (re.escape(settings.ADMINFILES_REF_START), 12 | re.escape(settings.ADMINFILES_REF_END))) 13 | UPLOAD_RE = _get_upload_re() 14 | 15 | def get_uploads(text): 16 | """ 17 | Return a generator yielding uploads referenced in the given text. 18 | 19 | """ 20 | uploads = [] 21 | for match in UPLOAD_RE.finditer(text): 22 | try: 23 | upload = FileUpload.objects.get(slug=match.group(1)) 24 | except FileUpload.DoesNotExist: 25 | continue 26 | yield upload 27 | 28 | def substitute_uploads(text, sub_callback): 29 | """ 30 | Return text with all upload references substituted using 31 | sub_callback, which must accept an re match object and return the 32 | replacement string. 33 | 34 | """ 35 | return UPLOAD_RE.sub(sub_callback, text) 36 | 37 | def parse_match(match): 38 | """ 39 | Accept an re match object resulting from an ``UPLOAD_RE`` match 40 | and return a two-tuple where the first element is the 41 | corresponding ``FileUpload`` and the second is a dictionary of the 42 | key=value options. 43 | 44 | If there is no ``FileUpload`` object corresponding to the match, 45 | the first element of the returned tuple is None. 46 | 47 | """ 48 | try: 49 | upload = FileUpload.objects.get(slug=match.group(1)) 50 | except FileUpload.DoesNotExist: 51 | upload = None 52 | options = parse_options(match.group(2)) 53 | return (upload, options) 54 | 55 | def parse_options(s): 56 | """ 57 | Expects a string in the form "key=val:key2=val2" and returns a 58 | dictionary. 59 | 60 | """ 61 | options = {} 62 | for option in s.split(':'): 63 | if '=' in option: 64 | key, val = option.split('=') 65 | options[str(key).strip()] = val.strip() 66 | return options 67 | -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/adminfiles.css: -------------------------------------------------------------------------------- 1 | #content { margin:10px 0; } 2 | #container { min-width:0; } 3 | #user-tools { display:none; } 4 | #footer { display:none; } 5 | 6 | #adminfiles { 7 | list-style-type:none; 8 | padding:0; 9 | position:absolute; 10 | top:0px; 11 | left:110px; 12 | } 13 | 14 | #adminfiles .nav { 15 | list-style-type:none; 16 | width:100%; 17 | height: 2em; 18 | } 19 | 20 | #adminfiles .nav .next { 21 | float:right; 22 | } 23 | 24 | #adminfiles .nav .prev { 25 | float:left; 26 | } 27 | 28 | .item { 29 | float:left; 30 | overflow:hidden; 31 | border:1px solid #ccc; 32 | background:#fafafa url(mimetypes/empty.png) no-repeat 41px 70px; 33 | text-align:center; 34 | cursor:pointer; 35 | width:144px; 36 | height:150px; 37 | margin:0 11px 12px 0; 38 | } 39 | 40 | .upload-title { 41 | background:#fff; 42 | opacity:0.8; 43 | } 44 | 45 | .popup { 46 | position:absolute; 47 | z-index:100; 48 | opacity:0.9; 49 | background:#eee; 50 | width:126px; 51 | height:120px; 52 | padding:5px; 53 | display:none; 54 | text-align:left; 55 | margin:15px 0 0 3px; 56 | border:1px solid #aaa; 57 | border-top-color:#ddd; 58 | border-left-color:#ddd; 59 | } 60 | 61 | .popup ul { padding:0; } 62 | .popup li { list-style-type:none; } 63 | .popup .close { float:right; color:#CC3434; } 64 | .popup input { width:116px; } 65 | .popup .deletelink { position: absolute; left: 5px; bottom: 10px; } 66 | .popup .changelink { position: absolute; right: 5px; bottom: 10px; } 67 | 68 | #adminfiles-filter { 69 | position:fixed; 70 | left:0; 71 | width:100%; 72 | margin-left: -30px; 73 | } 74 | 75 | #adminfiles-filter li { 76 | list-style-type:none; 77 | } 78 | 79 | #adminfiles-filter li.upload { 80 | margin-top: 10px; 81 | } 82 | 83 | /* mimetypes, icons from Nuvola */ 84 | .image, .video { 85 | background-image:none; 86 | background-position:center center; 87 | } 88 | 89 | .pdf { background-image:url(mimetypes/pdf.png); } 90 | .msword { background-image:url(mimetypes/doc.png); } 91 | .vndms-excel { background-image:url(mimetypes/xls.png); } 92 | .zip, .x-tar { background-image:url(mimetypes/zip.png); } 93 | 94 | -------------------------------------------------------------------------------- /adminfiles/static/adminfiles/adminfiles.js: -------------------------------------------------------------------------------- 1 | function insertAtCursor(myField, myValue) { 2 | //IE support 3 | if (document.selection) { 4 | myField.focus(); 5 | sel = document.selection.createRange(); 6 | sel.text = myValue; 7 | } 8 | //MOZILLA/NETSCAPE support 9 | else if (myField.selectionStart || myField.selectionStart == '0') { 10 | var startPos = myField.selectionStart; 11 | var endPos = myField.selectionEnd; 12 | myField.value = myField.value.substring(0, startPos) 13 | + myValue 14 | + myField.value.substring(endPos, myField.value.length); 15 | } else { 16 | myField.value += myValue; 17 | } 18 | } 19 | 20 | function showEditPopup(triggeringLink) { 21 | var name = 'edit_popup'; 22 | var href = triggeringLink.href; 23 | if (href.indexOf('?') == -1) { 24 | href += '?_popup=1'; 25 | } else { 26 | href += '&_popup=1'; 27 | } 28 | var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); 29 | win.focus(); 30 | return false; 31 | } 32 | 33 | function dismissEditPopup(win) { 34 | location.reload(true); 35 | win.close(); 36 | } 37 | 38 | function showAddUploadPopup(triggeringLink) { 39 | var name = 'add_upload_popup'; 40 | var href = triggeringLink.href; 41 | if (href.indexOf('?') == -1) { 42 | href += '?_popup=1'; 43 | } else { 44 | href += '&_popup=1'; 45 | } 46 | var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); 47 | win.focus(); 48 | return false; 49 | } 50 | 51 | function dismissAddUploadPopup(win) { 52 | location.reload(true); 53 | win.close(); 54 | } 55 | 56 | $(function(){ 57 | $('#adminfiles li').click( 58 | function(){ 59 | $(this).children('.popup').show(); 60 | }); 61 | $('.popup .close').click( 62 | function(){ 63 | $(this).parent('.popup').hide(); 64 | return false; 65 | }); 66 | $('.popup .select').click( 67 | function(){ 68 | for (i=0; i' 19 | 'opener.dismissEditPopup(window);' 20 | '') 21 | return super(FileUploadAdmin, self).response_change(request, obj) 22 | 23 | def delete_view(self, request, *args, **kwargs): 24 | response = super(FileUploadAdmin, self).delete_view(request, 25 | *args, 26 | **kwargs) 27 | if request.POST.has_key("post") and request.GET.has_key("_popup"): 28 | return HttpResponse('') 31 | return response 32 | 33 | def response_add(self, request, *args, **kwargs): 34 | if request.POST.has_key('_popup'): 35 | return HttpResponse('') 38 | return super(FileUploadAdmin, self).response_add(request, 39 | *args, 40 | **kwargs) 41 | 42 | 43 | class FilePickerAdmin(admin.ModelAdmin): 44 | adminfiles_fields = [] 45 | 46 | def __init__(self, *args, **kwargs): 47 | super(FilePickerAdmin, self).__init__(*args, **kwargs) 48 | register_listeners(self.model, self.adminfiles_fields) 49 | 50 | def formfield_for_dbfield(self, db_field, **kwargs): 51 | field = super(FilePickerAdmin, self).formfield_for_dbfield( 52 | db_field, **kwargs) 53 | if db_field.name in self.adminfiles_fields: 54 | try: 55 | field.widget.attrs['class'] += " adminfilespicker" 56 | except KeyError: 57 | field.widget.attrs['class'] = 'adminfilespicker' 58 | return field 59 | 60 | class Media: 61 | js = [JQUERY_URL, 'adminfiles/model.js'] 62 | 63 | admin.site.register(FileUpload, FileUploadAdmin) 64 | -------------------------------------------------------------------------------- /adminfiles/listeners.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from django.db.models.signals import pre_save, post_save, pre_delete 4 | 5 | from django.contrib.contenttypes.models import ContentType 6 | 7 | from adminfiles.models import FileUpload, FileUploadReference 8 | from adminfiles.parse import get_uploads 9 | from adminfiles import settings 10 | 11 | def get_ctype_kwargs(obj): 12 | return {'content_type': ContentType.objects.get_for_model(obj), 13 | 'object_id': obj.id} 14 | 15 | def _get_field(instance, field): 16 | """ 17 | This is here to support ``MarkupField``. It's a little ugly to 18 | have that support baked-in; other option would be to have a 19 | generic way (via setting?) to override how attribute values are 20 | fetched from content model instances. 21 | 22 | """ 23 | 24 | value = getattr(instance, field) 25 | if hasattr(value, 'raw'): 26 | value = value.raw 27 | return value 28 | 29 | referring_models = set() 30 | 31 | def register_listeners(model, fields): 32 | 33 | def _update_references(sender, instance, **kwargs): 34 | ref_kwargs = get_ctype_kwargs(instance) 35 | for upload in chain(*[get_uploads(_get_field(instance, field)) 36 | for field in fields]): 37 | FileUploadReference.objects.get_or_create(**dict(ref_kwargs, 38 | upload=upload)) 39 | 40 | def _delete_references(sender, instance, **kwargs): 41 | ref_kwargs = get_ctype_kwargs(instance) 42 | FileUploadReference.objects.filter(**ref_kwargs).delete() 43 | 44 | if settings.ADMINFILES_USE_SIGNALS: 45 | referring_models.add(model) 46 | post_save.connect(_update_references, sender=model, weak=False) 47 | pre_delete.connect(_delete_references, sender=model, weak=False) 48 | 49 | 50 | def _update_content(sender, instance, created=None, **kwargs): 51 | """ 52 | Re-save any content models referencing the just-modified 53 | ``FileUpload``. 54 | 55 | We don't do anything special to the content model, we just re-save 56 | it. If signals are in use, we assume that the content model has 57 | incorporated ``render_uploads`` into some kind of rendering that 58 | happens automatically at save-time. 59 | 60 | """ 61 | if created: # a brand new FileUpload won't be referenced 62 | return 63 | for ref in FileUploadReference.objects.filter(upload=instance): 64 | try: 65 | obj = ref.content_object 66 | if obj: 67 | obj.save() 68 | except AttributeError: 69 | pass 70 | 71 | def _register_upload_listener(): 72 | if settings.ADMINFILES_USE_SIGNALS: 73 | post_save.connect(_update_content, sender=FileUpload) 74 | _register_upload_listener() 75 | 76 | def _disconnect_upload_listener(): 77 | post_save.disconnect(_update_content) 78 | -------------------------------------------------------------------------------- /adminfiles/templates/adminfiles/uploader/base.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | {% load url from future %} 3 | {% load thumbnail i18n static %} 4 | 5 | {% block extrastyle %} 6 | 7 | 8 | 13 | 14 | 15 | {% endblock %} 16 | {% block bodyclass %}adminfiles{% endblock %} 17 | {% block breadcrumbs %}{% endblock %} 18 | {% block userlinks %}{% endblock %} 19 | {% block content %} 20 |
    21 |
    34 | 35 |
      36 | {% block files %} 37 | {% for f in files %} 38 |
    • 39 | 57 |
      {{f.title}}
      58 | {{f.upload_date|date:"F j, Y"}}
      59 | {{f.description}}
      60 |
    • 61 | {% endfor %} 62 | {% endblock %} 63 |
    64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /adminfiles/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-adminfiles\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2010-03-17 21:13+0100\n" 11 | "PO-Revision-Date: 2010-03-17 12:19+0100\n" 12 | "Last-Translator: Jannis Leidel \n" 13 | "Language-Team: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "X-Poedit-Language: German\n" 18 | "X-Poedit-Country: GERMANY\n" 19 | 20 | #: models.py:21 21 | msgid "upload date" 22 | msgstr "Upload-Datum" 23 | 24 | #: models.py:22 25 | msgid "file" 26 | msgstr "Datei" 27 | 28 | #: models.py:23 29 | msgid "title" 30 | msgstr "Titel" 31 | 32 | #: models.py:24 33 | msgid "slug" 34 | msgstr "Kürzel" 35 | 36 | #: models.py:25 37 | msgid "description" 38 | msgstr "Beschreibung" 39 | 40 | #: models.py:30 41 | msgid "tags" 42 | msgstr "Tags" 43 | 44 | #: models.py:34 45 | msgid "file upload" 46 | msgstr "Datei-Upload" 47 | 48 | #: models.py:35 49 | msgid "file uploads" 50 | msgstr "Datei-Uploads" 51 | 52 | #: models.py:42 53 | msgid "mime type" 54 | msgstr "MIME-Type" 55 | 56 | #: settings.py:34 57 | msgid "Insert Link" 58 | msgstr "Link einfügen" 59 | 60 | #: settings.py:35 61 | msgid "Insert" 62 | msgstr "Einfügen" 63 | 64 | #: settings.py:36 65 | msgid "Insert (align left)" 66 | msgstr "Einfügen (linksbündig)" 67 | 68 | #: settings.py:37 69 | msgid "Insert (align right)" 70 | msgstr "Einfügen (rechtsbündig)" 71 | 72 | #: views.py:71 73 | msgid "All Uploads" 74 | msgstr "Alle Uploads" 75 | 76 | #: views.py:84 77 | msgid "Images" 78 | msgstr "Bilder" 79 | 80 | #: views.py:91 81 | msgid "Audio" 82 | msgstr "Audio" 83 | 84 | #: views.py:98 85 | msgid "Files" 86 | msgstr "Dateien" 87 | 88 | #: templates/adminfiles/uploader/base.html:27 89 | msgid "Upload" 90 | msgstr "Upload" 91 | 92 | #: templates/adminfiles/uploader/base.html:30 93 | msgid "Refresh" 94 | msgstr "Aktualisieren" 95 | 96 | #: templates/adminfiles/uploader/base.html:42 97 | msgid "Select" 98 | msgstr "Auswählen" 99 | 100 | #: templates/adminfiles/uploader/base.html:53 101 | msgid "Delete" 102 | msgstr "Löschen" 103 | 104 | #: templates/adminfiles/uploader/base.html:54 105 | msgid "Change" 106 | msgstr "Ändern" 107 | 108 | #: templates/adminfiles/uploader/flickr.html:6 109 | msgid "Newer" 110 | msgstr "Neuer" 111 | 112 | #: templates/adminfiles/uploader/flickr.html:8 113 | msgid "Older" 114 | msgstr "Älter" 115 | 116 | #: templates/adminfiles/uploader/flickr.html:15 117 | msgid "Insert Photo" 118 | msgstr "Photo einfügen" 119 | 120 | #: templates/adminfiles/uploader/video.html:7 121 | msgid "Close" 122 | msgstr "Schließen" 123 | 124 | #: templates/adminfiles/uploader/video.html:9 125 | msgid "Insert Video" 126 | msgstr "Video einfügen" 127 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | tip (unreleased) 5 | ---------------- 6 | 7 | 1.0.1 (2013.06.15) 8 | ------------------ 9 | 10 | - Fix for non-local file storages. Thanks vitaly4uk. 11 | 12 | - Fix MANIFEST.in so static assets are included with release tarball. Thanks 13 | StillNewb for the report. 14 | 15 | 16 | 1.0 (2013.06.06) 17 | ---------------- 18 | 19 | - BACKWARDS-INCOMPATIBLE: Django versions prior to 1.4 are no longer tested or supported. 20 | 21 | - BACKWARDS-INCOMPATIBLE: Removed the ``ADMINFILES_MEDIA_URL`` setting, use 22 | ``STATIC_URL`` everywhere for static assets. Thanks Rudolph Froger for the 23 | nudge. 24 | 25 | - Updated to most recent sorl-thumbnail. Thanks Svyatoslav Bulbakha. 26 | 27 | - Added Russian translation. Thanks Svyatoslav Bulbakha. 28 | 29 | - Added Spanish translation. Thanks Andrés Reyes Monge. 30 | 31 | - Updated to use Django 1.3's class-based views. Fixes #10. Thanks Andrés 32 | Reyes Monge and Ales Zabala Alava. 33 | 34 | 35 | 0.5.1 (2011.03.22) 36 | ------------------ 37 | 38 | - Added support for djangoembed as well as django-oembed. 39 | 40 | - Added support for multiple pages of Vimeo results via 41 | ADMINFILES_VIMEO_PAGES setting (defaults to 1). 42 | 43 | - Added German translation. Thanks Jannis Leidel. 44 | 45 | 46 | 47 | 0.5.0 (2010.03.09) 48 | ------------------ 49 | 50 | - Added ``as`` template override keyword option 51 | 52 | - Added ``render_upload`` filter 53 | 54 | - Added YouTube, Flickr, Vimeo browsers 55 | 56 | - Added OEmbed support 57 | 58 | - Added translation hooks and Polish translation: thanks Ludwik Trammer! 59 | 60 | - Added support for linking full set of mime-type icons from stdicon.com. 61 | 62 | - Made the JS reference-insertion options configurable. 63 | 64 | - BACKWARDS-INCOMPATIBLE: default rendering template is now 65 | ``adminfiles/render/default.html`` instead of 66 | ``adminfiles/render.html``. Image-specific rendering should 67 | override ``adminfiles/render/image/default.html`` instead of testing 68 | ``upload.is_image`` in default template. 69 | 70 | - Added per-mime-type template rendering 71 | 72 | - Upgraded to jQuery 1.4 73 | 74 | - Fixed bug where YouTube and Flickr links showed up even when disabled. 75 | 76 | - Added sync_upload_refs command 77 | 78 | 79 | 0.3.4 (2009.12.03) 80 | ------------------ 81 | 82 | - Fixed over-eager escaping in render_uploads template tag. 83 | 84 | 85 | 0.3.3 (2009.12.02) 86 | ------------------ 87 | 88 | - Fixed insertion of slugs for non-image files. 89 | 90 | 91 | 0.3.2 (2009.12.02) 92 | ------------------ 93 | 94 | - Fixed setup.py package_data so media and templates are installed from sdist. 95 | 96 | 97 | 0.3.1 (2009.11.25) 98 | ------------------ 99 | 100 | - Fixed setup.py so ``tests`` package is not installed. 101 | 102 | 103 | 0.3.0 (2009.11.23) 104 | ------------------ 105 | 106 | - Initial release as ``django-adminfiles`` 107 | 108 | - Added docs and test suite 109 | 110 | - Added reference parsing & rendering, template filter, signal handling 111 | 112 | -------------------------------------------------------------------------------- /adminfiles/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-adminfiles\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2012-01-24 00:35-0600\n" 12 | "PO-Revision-Date: 2011-01-24 1\n" 13 | "Last-Translator: Andrés Reyes Monge \n" 14 | "Language-Team: \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "X-Poedit-Language: Spanish\n" 20 | "X-Poedit-Country: NICARAGUA\n" 21 | 22 | #: models.py:21 23 | msgid "upload date" 24 | msgstr "fecha de carga" 25 | 26 | #: models.py:22 27 | msgid "file" 28 | msgstr "archivo" 29 | 30 | #: models.py:23 31 | msgid "title" 32 | msgstr "titulo" 33 | 34 | #: models.py:24 35 | msgid "slug" 36 | msgstr "slug" 37 | 38 | #: models.py:25 39 | msgid "description" 40 | msgstr "descripción" 41 | 42 | #: models.py:30 43 | msgid "tags" 44 | msgstr "etiquetas" 45 | 46 | #: models.py:34 47 | msgid "file upload" 48 | msgstr "carga de archivo" 49 | 50 | #: models.py:35 51 | msgid "file uploads" 52 | msgstr "archivos cargados" 53 | 54 | #: models.py:42 55 | msgid "mime type" 56 | msgstr "tipo mime" 57 | 58 | #: settings.py:34 59 | msgid "Insert Link" 60 | msgstr "Insertar enlace" 61 | 62 | #: settings.py:35 63 | msgid "Insert" 64 | msgstr "Insertar" 65 | 66 | #: settings.py:36 67 | msgid "Insert (align left)" 68 | msgstr "Insertar (alineado a la izquierda)" 69 | 70 | #: settings.py:37 71 | msgid "Insert (align right)" 72 | msgstr "Insertar (alineado a la derecha)" 73 | 74 | #: views.py:76 75 | msgid "All Uploads" 76 | msgstr "Todas las cargas" 77 | 78 | #: views.py:90 79 | msgid "Images" 80 | msgstr "Imagenes" 81 | 82 | #: views.py:97 83 | msgid "Audio" 84 | msgstr "Audio" 85 | 86 | #: views.py:104 87 | msgid "Files" 88 | msgstr "Archivos" 89 | 90 | #: templates/adminfiles/uploader/base.html:28 91 | msgid "Upload" 92 | msgstr "Cargas" 93 | 94 | #: templates/adminfiles/uploader/base.html:31 95 | msgid "Refresh" 96 | msgstr "Refrescar" 97 | 98 | #: templates/adminfiles/uploader/base.html:43 99 | msgid "Select" 100 | msgstr "Seleccionar" 101 | 102 | #: templates/adminfiles/uploader/base.html:54 103 | msgid "Delete" 104 | msgstr "Borrar" 105 | 106 | #: templates/adminfiles/uploader/base.html:55 107 | msgid "Change" 108 | msgstr "Cambiar" 109 | 110 | #: templates/adminfiles/uploader/flickr.html:6 111 | msgid "Newer" 112 | msgstr "Más nuevos" 113 | 114 | #: templates/adminfiles/uploader/flickr.html:8 115 | msgid "Older" 116 | msgstr "Más viejos" 117 | 118 | #: templates/adminfiles/uploader/flickr.html:15 119 | msgid "Insert Photo" 120 | msgstr "Insertar fotografía" 121 | 122 | #: templates/adminfiles/uploader/video.html:7 123 | msgid "Close" 124 | msgstr "Cerrar" 125 | 126 | #: templates/adminfiles/uploader/video.html:9 127 | msgid "Insert Video" 128 | msgstr "Insertar Video" 129 | -------------------------------------------------------------------------------- /adminfiles/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-adminfiles\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2010-03-06 20:18+0100\n" 11 | "PO-Revision-Date: 2010-03-06 20:20+0100\n" 12 | "Last-Translator: Ludwik Trammer \n" 13 | "Language-Team: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "X-Poedit-Language: Polish\n" 18 | "X-Poedit-Country: POLAND\n" 19 | 20 | #: models.py:21 21 | msgid "upload date" 22 | msgstr "data dodania" 23 | 24 | #: models.py:22 25 | msgid "file" 26 | msgstr "plik" 27 | 28 | #: models.py:23 29 | msgid "title" 30 | msgstr "tytuł" 31 | 32 | #: models.py:24 33 | msgid "slug" 34 | msgstr "identyfikator" 35 | 36 | #: models.py:25 37 | msgid "description" 38 | msgstr "opis" 39 | 40 | #: models.py:30 41 | msgid "tags" 42 | msgstr "tagi" 43 | 44 | #: models.py:34 45 | msgid "file upload" 46 | msgstr "przesyłanie pliku" 47 | 48 | #: models.py:35 49 | msgid "file uploads" 50 | msgstr "przesyłanie plików" 51 | 52 | #: models.py:42 53 | msgid "mime type" 54 | msgstr "typ pliku" 55 | 56 | #: settings.py:34 57 | msgid "Insert Link" 58 | msgstr "Wstaw odnośnik" 59 | 60 | #: settings.py:35 61 | msgid "Insert" 62 | msgstr "Wstaw" 63 | 64 | #: settings.py:36 65 | msgid "Insert (align left)" 66 | msgstr "Wstaw (po lewej)" 67 | 68 | #: settings.py:37 69 | msgid "Insert (align right)" 70 | msgstr "Wstaw (po prawej)" 71 | 72 | #: views.py:71 73 | msgid "All Uploads" 74 | msgstr "Wszystkie" 75 | 76 | #: views.py:84 77 | msgid "Images" 78 | msgstr "Obrazki" 79 | 80 | #: views.py:91 81 | msgid "Audio" 82 | msgstr "Audio" 83 | 84 | #: views.py:98 85 | msgid "Files" 86 | msgstr "Pliki" 87 | 88 | #: templates/adminfiles/uploader/base.html:27 89 | msgid "Upload" 90 | msgstr "Dodaj" 91 | 92 | #: templates/adminfiles/uploader/base.html:30 93 | msgid "Refresh" 94 | msgstr "Odśwież" 95 | 96 | #: templates/adminfiles/uploader/base.html:42 97 | msgid "Select" 98 | msgstr "Wybierz" 99 | 100 | #: templates/adminfiles/uploader/base.html:53 101 | msgid "Delete" 102 | msgstr "Usuń" 103 | 104 | #: templates/adminfiles/uploader/base.html:54 105 | msgid "Change" 106 | msgstr "Zmień" 107 | 108 | #: templates/adminfiles/uploader/flickr.html:6 109 | msgid "Newer" 110 | msgstr "Nowsze" 111 | 112 | #: templates/adminfiles/uploader/flickr.html:8 113 | msgid "Older" 114 | msgstr "Starsze" 115 | 116 | #: templates/adminfiles/uploader/flickr.html:15 117 | msgid "Insert Photo" 118 | msgstr "Wstaw zdjęcie" 119 | 120 | #: templates/adminfiles/uploader/video.html:7 121 | msgid "Close" 122 | msgstr "Zamknij" 123 | 124 | #: templates/adminfiles/uploader/video.html:9 125 | msgid "Insert Video" 126 | msgstr "Wstaw wideo" 127 | 128 | #~ msgid "Edit" 129 | #~ msgstr "Edytuj" 130 | 131 | #~ msgid "Insert (left)" 132 | #~ msgstr "Wstaw (po lewej)" 133 | -------------------------------------------------------------------------------- /adminfiles/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) 2012 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Svyatoslav Bulbakha , 2012 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2012-02-14 15:35+0400\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%" 20 | "10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)\n" 21 | 22 | #: models.py:21 23 | msgid "upload date" 24 | msgstr "дата загрузки" 25 | 26 | #: models.py:22 27 | msgid "file" 28 | msgstr "файл" 29 | 30 | #: models.py:23 31 | msgid "title" 32 | msgstr "название" 33 | 34 | #: models.py:24 35 | msgid "slug" 36 | msgstr "слаг" 37 | 38 | #: models.py:25 39 | msgid "description" 40 | msgstr "описание" 41 | 42 | #: models.py:30 43 | msgid "tags" 44 | msgstr "теги" 45 | 46 | #: models.py:34 47 | msgid "file upload" 48 | msgstr "загруженный файл" 49 | 50 | #: models.py:35 51 | msgid "file uploads" 52 | msgstr "загруженные файлы" 53 | 54 | #: models.py:42 55 | msgid "mime type" 56 | msgstr "MIME-тип" 57 | 58 | #: settings.py:34 59 | msgid "Insert Link" 60 | msgstr "Вставить ссылку" 61 | 62 | #: settings.py:35 63 | msgid "Insert" 64 | msgstr "Вставить" 65 | 66 | #: settings.py:36 67 | msgid "Insert (align left)" 68 | msgstr "Вставить (выровнять по левому краю)" 69 | 70 | #: settings.py:37 71 | msgid "Insert (align right)" 72 | msgstr "Вставить (выровнять по правому краю)" 73 | 74 | #: views.py:76 75 | msgid "All Uploads" 76 | msgstr "Все загрузки" 77 | 78 | #: views.py:90 79 | msgid "Images" 80 | msgstr "Изображения" 81 | 82 | #: views.py:97 83 | msgid "Audio" 84 | msgstr "Аудио" 85 | 86 | #: views.py:104 87 | msgid "Files" 88 | msgstr "Файлы" 89 | 90 | #: templates/adminfiles/uploader/base.html:28 91 | msgid "Upload" 92 | msgstr "Загрузить" 93 | 94 | #: templates/adminfiles/uploader/base.html:31 95 | msgid "Refresh" 96 | msgstr "Обновить" 97 | 98 | #: templates/adminfiles/uploader/base.html:43 99 | msgid "Select" 100 | msgstr "Выбрать" 101 | 102 | #: templates/adminfiles/uploader/base.html:54 103 | msgid "Delete" 104 | msgstr "Удалить" 105 | 106 | #: templates/adminfiles/uploader/base.html:55 107 | msgid "Change" 108 | msgstr "Изменить" 109 | 110 | #: templates/adminfiles/uploader/flickr.html:6 111 | msgid "Newer" 112 | msgstr "Новые" 113 | 114 | #: templates/adminfiles/uploader/flickr.html:8 115 | msgid "Older" 116 | msgstr "Старые" 117 | 118 | #: templates/adminfiles/uploader/flickr.html:15 119 | msgid "Insert Photo" 120 | msgstr "Вставить фотография" 121 | 122 | #: templates/adminfiles/uploader/video.html:7 123 | msgid "Close" 124 | msgstr "Закрыть" 125 | 126 | #: templates/adminfiles/uploader/video.html:9 127 | msgid "Insert Video" 128 | msgstr "Вставить видео" 129 | -------------------------------------------------------------------------------- /adminfiles/utils.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | 3 | from django import template 4 | from django.conf import settings 5 | 6 | if 'oembed' in settings.INSTALLED_APPS: 7 | try: 8 | # djangoembed 9 | from oembed.consumer import OEmbedConsumer 10 | def oembed_replace(text): 11 | consumer = OEmbedConsumer() 12 | return consumer.parse(text) 13 | except ImportError: 14 | # django-oembed 15 | from oembed.core import replace as oembed_replace 16 | else: 17 | oembed_replace = lambda s: s 18 | 19 | from adminfiles.parse import parse_match, substitute_uploads 20 | from adminfiles import settings 21 | 22 | def render_uploads(content, template_path="adminfiles/render/"): 23 | """ 24 | Replace all uploaded file references in a content string with the 25 | results of rendering a template found under ``template_path`` with 26 | the ``FileUpload`` instance and the key=value options found in the 27 | file reference. 28 | 29 | So if "<<>>" is found in the 30 | content string, it will be replaced with the results of rendering 31 | the selected template with ``upload`` set to the ``FileUpload`` 32 | instance with slug "my-uploaded-file" and ``options`` set to 33 | {'key': 'val', 'key2': 'val2'}. 34 | 35 | If the given slug is not found, the reference is replaced with the 36 | empty string. 37 | 38 | If ``djangoembed`` or ``django-oembed`` is installed, also replaces OEmbed 39 | URLs with the appropriate embed markup. 40 | 41 | """ 42 | def _replace(match): 43 | upload, options = parse_match(match) 44 | return render_upload(upload, template_path, **options) 45 | return oembed_replace(substitute_uploads(content, _replace)) 46 | 47 | 48 | def render_upload(upload, template_path="adminfiles/render/", **options): 49 | """ 50 | Render a single ``FileUpload`` model instance using the 51 | appropriate rendering template and the given keyword options, and 52 | return the rendered HTML. 53 | 54 | The template used to render each upload is selected based on the 55 | mime-type of the upload. For an upload with mime-type 56 | "image/jpeg", assuming the default ``template_path`` of 57 | "adminfiles/render", the template used would be the first one 58 | found of the following: ``adminfiles/render/image/jpeg.html``, 59 | ``adminfiles/render/image/default.html``, and 60 | ``adminfiles/render/default.html`` 61 | 62 | """ 63 | if upload is None: 64 | return settings.ADMINFILES_STRING_IF_NOT_FOUND 65 | template_name = options.pop('as', None) 66 | if template_name: 67 | templates = [template_name, 68 | "%s/default" % template_name.split('/')[0], 69 | "default"] 70 | else: 71 | templates = [join(upload.content_type, upload.sub_type), 72 | join(upload.content_type, "default"), 73 | "default"] 74 | tpl = template.loader.select_template( 75 | ["%s.html" % join(template_path, p) for p in templates]) 76 | return tpl.render(template.Context({'upload': upload, 77 | 'options': options})) 78 | -------------------------------------------------------------------------------- /adminfiles/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mimetypes 3 | 4 | from django.conf import settings as django_settings 5 | from django.db import models 6 | from django.template.defaultfilters import slugify 7 | from django.core.files.images import get_image_dimensions 8 | from django.utils.translation import ugettext_lazy as _ 9 | 10 | from django.contrib.contenttypes.models import ContentType 11 | from django.contrib.contenttypes import generic 12 | 13 | from adminfiles import settings 14 | 15 | if 'tagging' in django_settings.INSTALLED_APPS: 16 | from tagging.fields import TagField 17 | else: 18 | TagField = None 19 | 20 | class FileUpload(models.Model): 21 | upload_date = models.DateTimeField(_('upload date'), auto_now_add=True) 22 | upload = models.FileField(_('file'), upload_to=settings.ADMINFILES_UPLOAD_TO) 23 | title = models.CharField(_('title'), max_length=100) 24 | slug = models.SlugField(_('slug'), max_length=100, unique=True) 25 | description = models.CharField(_('description'), blank=True, max_length=200) 26 | content_type = models.CharField(editable=False, max_length=100) 27 | sub_type = models.CharField(editable=False, max_length=100) 28 | 29 | if TagField: 30 | tags = TagField(_('tags')) 31 | 32 | class Meta: 33 | ordering = ['upload_date', 'title'] 34 | verbose_name = _('file upload') 35 | verbose_name_plural = _('file uploads') 36 | 37 | def __unicode__(self): 38 | return self.title 39 | 40 | def mime_type(self): 41 | return '%s/%s' % (self.content_type, self.sub_type) 42 | mime_type.short_description = _('mime type') 43 | 44 | def type_slug(self): 45 | return slugify(self.sub_type) 46 | 47 | def is_image(self): 48 | return self.content_type == 'image' 49 | 50 | def _get_dimensions(self): 51 | try: 52 | return self._dimensions_cache 53 | except AttributeError: 54 | if self.is_image(): 55 | self._dimensions_cache = get_image_dimensions(self.upload.path) 56 | else: 57 | self._dimensions_cache = (None, None) 58 | return self._dimensions_cache 59 | 60 | def width(self): 61 | return self._get_dimensions()[0] 62 | 63 | def height(self): 64 | return self._get_dimensions()[1] 65 | 66 | def save(self, *args, **kwargs): 67 | try: 68 | uri = self.upload.path 69 | except NotImplementedError: 70 | uri = self.upload.url 71 | (mime_type, encoding) = mimetypes.guess_type(uri) 72 | try: 73 | [self.content_type, self.sub_type] = mime_type.split('/') 74 | except: 75 | self.content_type = 'text' 76 | self.sub_type = 'plain' 77 | super(FileUpload, self).save() 78 | 79 | def insert_links(self): 80 | links = [] 81 | for key in [self.mime_type(), self.content_type, '']: 82 | if key in settings.ADMINFILES_INSERT_LINKS: 83 | links = settings.ADMINFILES_INSERT_LINKS[key] 84 | break 85 | for link in links: 86 | ref = self.slug 87 | opts = ':'.join(['%s=%s' % (k,v) for k,v in link[1].items()]) 88 | if opts: 89 | ref += ':' + opts 90 | yield {'desc': link[0], 91 | 'ref': ref} 92 | 93 | def mime_image(self): 94 | if not settings.ADMINFILES_STDICON_SET: 95 | return None 96 | return ('http://www.stdicon.com/%s/%s?size=64' 97 | % (settings.ADMINFILES_STDICON_SET, self.mime_type())) 98 | 99 | 100 | 101 | class FileUploadReference(models.Model): 102 | """ 103 | Tracks which ``FileUpload``s are referenced by which content models. 104 | 105 | """ 106 | upload = models.ForeignKey(FileUpload) 107 | content_type = models.ForeignKey(ContentType) 108 | object_id = models.PositiveIntegerField() 109 | content_object = generic.GenericForeignKey('content_type', 'object_id') 110 | 111 | class Meta: 112 | unique_together = ('upload', 'content_type', 'object_id') 113 | -------------------------------------------------------------------------------- /adminfiles/views.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | 3 | from django.http import HttpResponse 4 | from django.conf import settings as django_settings 5 | from django.core.urlresolvers import reverse 6 | from django.utils.translation import ugettext_lazy as _ 7 | from django.views.generic import TemplateView 8 | 9 | from adminfiles.models import FileUpload 10 | from adminfiles import settings 11 | 12 | class DisableView(Exception): 13 | pass 14 | 15 | class BaseView(TemplateView): 16 | template_name = 'adminfiles/uploader/base.html' 17 | 18 | def get_context_data(self, **kwargs): 19 | context = super(BaseView, self).get_context_data(**kwargs) 20 | context.update({ 21 | 'browsers': get_enabled_browsers(), 22 | 'field_id': self.request.GET['field'], 23 | 'field_type': self.request.GET.get('field_type', 'textarea'), 24 | 'ADMINFILES_REF_START': settings.ADMINFILES_REF_START, 25 | 'ADMINFILES_REF_END': settings.ADMINFILES_REF_END, 26 | 'JQUERY_URL': settings.JQUERY_URL 27 | }) 28 | 29 | return context 30 | 31 | @classmethod 32 | def slug(cls): 33 | """ 34 | Return slug suitable for accessing this view in a URLconf. 35 | 36 | """ 37 | slug = cls.__name__.lower() 38 | if slug.endswith('view'): 39 | slug = slug[:-4] 40 | return slug 41 | 42 | @classmethod 43 | def link_text(cls): 44 | """ 45 | Return link text for this view. 46 | 47 | """ 48 | link = cls.__name__ 49 | if link.endswith('View'): 50 | link = link[:-4] 51 | return link 52 | 53 | @classmethod 54 | def url(cls): 55 | """ 56 | Return URL for this view. 57 | 58 | """ 59 | return reverse('adminfiles_%s' % cls.slug()) 60 | 61 | @classmethod 62 | def check(cls): 63 | """ 64 | Raise ``DisableView`` if the configuration necessary for this 65 | view is not active. 66 | 67 | """ 68 | pass 69 | 70 | 71 | class AllView(BaseView): 72 | link_text = _('All Uploads') 73 | 74 | def files(self): 75 | return FileUpload.objects.all() 76 | 77 | def get_context_data(self, **kwargs): 78 | context = super(AllView, self).get_context_data(**kwargs) 79 | context.update({ 80 | 'files': self.files().order_by(*settings.ADMINFILES_THUMB_ORDER) 81 | }) 82 | return context 83 | 84 | 85 | class ImagesView(AllView): 86 | link_text = _('Images') 87 | 88 | def files(self): 89 | return super(ImagesView, self).files().filter(content_type='image') 90 | 91 | 92 | class AudioView(AllView): 93 | link_text = _('Audio') 94 | 95 | def files(self): 96 | return super(AudioView, self).files().filter(content_type='audio') 97 | 98 | 99 | class FilesView(AllView): 100 | link_text = _('Files') 101 | 102 | def files(self): 103 | not_files = ['video', 'image', 'audio'] 104 | return super(FilesView, self).files().exclude(content_type__in=not_files) 105 | 106 | class OEmbedView(BaseView): 107 | @classmethod 108 | def check(cls): 109 | if 'oembed' not in django_settings.INSTALLED_APPS: 110 | raise DisableView('OEmbed views require django-oembed or djangoembed. ' 111 | '(http://pypi.python.org/pypi/django-oembed, ' 112 | 'http://pypi.python.org/pypi/djangoembed)') 113 | 114 | class YouTubeView(OEmbedView): 115 | template_name = 'adminfiles/uploader/video.html' 116 | 117 | @classmethod 118 | def check(cls): 119 | super(YouTubeView, cls).check() 120 | try: 121 | from gdata.youtube.service import YouTubeService 122 | except ImportError: 123 | raise DisableView('YouTubeView requires "gdata" library ' 124 | '(http://pypi.python.org/pypi/gdata)') 125 | try: 126 | django_settings.ADMINFILES_YOUTUBE_USER 127 | except AttributeError: 128 | raise DisableView('YouTubeView requires ' 129 | 'ADMINFILES_YOUTUBE_USER setting') 130 | 131 | def get_context_data(self, **kwargs): 132 | context = super(YouTubeView, self).get_context_data(**kwargs) 133 | context.update({ 134 | 'videos': self.videos() 135 | }) 136 | return context 137 | 138 | def videos(self): 139 | from gdata.youtube.service import YouTubeService 140 | feed = YouTubeService().GetYouTubeVideoFeed( 141 | "http://gdata.youtube.com/feeds/videos?author=%s&orderby=updated" 142 | % django_settings.ADMINFILES_YOUTUBE_USER) 143 | videos = [] 144 | for entry in feed.entry: 145 | videos.append({ 146 | 'title': entry.media.title.text, 147 | 'upload_date': entry.published.text.split('T')[0], 148 | 'description': entry.media.description.text, 149 | 'thumb': entry.media.thumbnail[0].url, 150 | 'url': entry.media.player.url.split('&')[0], 151 | }) 152 | return videos 153 | 154 | 155 | class FlickrView(OEmbedView): 156 | template_name = 'adminfiles/uploader/flickr.html' 157 | 158 | @classmethod 159 | def check(cls): 160 | super(FlickrView, cls).check() 161 | try: 162 | import flickrapi 163 | except ImportError: 164 | raise DisableView('FlickrView requires the "flickrapi" library ' 165 | '(http://pypi.python.org/pypi/flickrapi)') 166 | try: 167 | django_settings.ADMINFILES_FLICKR_USER 168 | django_settings.ADMINFILES_FLICKR_API_KEY 169 | except AttributeError: 170 | raise DisableView('FlickrView requires ' 171 | 'ADMINFILES_FLICKR_USER and ' 172 | 'ADMINFILES_FLICKR_API_KEY settings') 173 | 174 | def get_context_data(self, **kwargs): 175 | context = super(FlickrView, self).get_context_data(**kwargs) 176 | page = int(request.GET.get('page', 1)) 177 | base_path = '%s?field=%s&page=' % (request.path, request.GET['field']) 178 | context['next_page'] = base_path + str(page + 1) 179 | if page > 1: 180 | context['prev_page'] = base_path + str(page - 1) 181 | else: 182 | context['prev_page'] = None 183 | context['photos'] = self.photos(page) 184 | return context 185 | 186 | def photos(self, page=1): 187 | import flickrapi 188 | user = django_settings.ADMINFILES_FLICKR_USER 189 | flickr = flickrapi.FlickrAPI(django_settings.ADMINFILES_FLICKR_API_KEY) 190 | # Get the user's NSID 191 | nsid = flickr.people_findByUsername( 192 | username=user).find('user').attrib['nsid'] 193 | # Get 12 photos for the user 194 | flickr_photos = flickr.people_getPublicPhotos( 195 | user_id=nsid, per_page=12, page=page).find('photos').findall('photo') 196 | photos = [] 197 | for f in flickr_photos: 198 | photo = {} 199 | photo['url'] = 'http://farm%(farm)s.static.flickr.com/%(server)s/%(id)s_%(secret)s_m.jpg' % f.attrib 200 | photo['link'] = 'http://www.flickr.com/photos/%s/%s' % ( 201 | nsid, f.attrib['id']) 202 | photo['title'] = f.attrib['title'] 203 | photos.append(photo) 204 | return photos 205 | 206 | 207 | class VimeoView(OEmbedView): 208 | template_name = 'adminfiles/uploader/video.html' 209 | 210 | @classmethod 211 | def check(cls): 212 | super(VimeoView, cls).check() 213 | try: 214 | django_settings.ADMINFILES_VIMEO_USER 215 | except AttributeError: 216 | raise DisableView('VimeoView requires ' 217 | 'ADMINFILES_VIMEO_USER setting') 218 | try: 219 | cls.pages = django_settings.ADMINFILES_VIMEO_PAGES 220 | except AttributeError: 221 | cls.pages = 1 222 | if cls.pages > 3: 223 | cls.pages = 3 224 | 225 | def get_context_data(self, **kwargs): 226 | context = super(VimeoView, self).get_context_data(**kwargs) 227 | context.update({ 228 | 'videos':self.videos() 229 | }) 230 | return context 231 | 232 | def _get_videos(self, url): 233 | import urllib2 234 | try: 235 | import xml.etree.ElementTree as ET 236 | except ImportError: 237 | import elementtree.ElementTree as ET 238 | request = urllib2.Request(url) 239 | request.add_header('User-Agent', 'django-adminfiles/0.x') 240 | root = ET.parse(urllib2.build_opener().open(request)).getroot() 241 | videos = [] 242 | for v in root.findall('video'): 243 | videos.append({ 244 | 'title': v.find('title').text, 245 | 'upload_date': v.find('upload_date').text.split()[0], 246 | 'description': v.find('description').text, 247 | 'thumb': v.find('thumbnail_small').text, 248 | 'url': v.find('url').text, 249 | }) 250 | return videos 251 | 252 | def videos(self): 253 | url = ('http://vimeo.com/api/v2/%s/videos.xml' 254 | % django_settings.ADMINFILES_VIMEO_USER) 255 | videos = self._get_videos(url) 256 | for page in range(2, self.pages + 1): 257 | page_url = "%s?page=%s" % (url, page) 258 | page_videos = self._get_videos(page_url) 259 | if not page_videos: 260 | break 261 | videos += page_videos 262 | return videos 263 | 264 | 265 | def download(request): 266 | '''Saves image from URL and returns ID for use with AJAX script''' 267 | f = FileUpload() 268 | f.title = request.GET['title'] or 'untitled' 269 | f.description = request.GET['description'] 270 | url = urllib.unquote(request.GET['photo']) 271 | file_content = urllib.urlopen(url).read() 272 | file_name = url.split('/')[-1] 273 | f.save_upload_file(file_name, file_content) 274 | f.save() 275 | return HttpResponse('%s' % (f.id)) 276 | 277 | 278 | _enabled_browsers_cache = None 279 | 280 | def get_enabled_browsers(): 281 | """ 282 | Check the ADMINFILES_BROWSER_VIEWS setting and return a list of 283 | instantiated browser views that have the necessary 284 | dependencies/configuration to run. 285 | 286 | """ 287 | global _enabled_browsers_cache 288 | if _enabled_browsers_cache is not None: 289 | return _enabled_browsers_cache 290 | enabled = [] 291 | for browser_path in settings.ADMINFILES_BROWSER_VIEWS: 292 | try: 293 | view_class = import_browser(browser_path) 294 | except ImportError: 295 | continue 296 | if not issubclass(view_class, BaseView): 297 | continue 298 | browser = view_class 299 | try: 300 | browser.check() 301 | except DisableView: 302 | continue 303 | enabled.append(browser) 304 | _enabled_browsers_cache = enabled 305 | return enabled 306 | 307 | def import_browser(path): 308 | module, classname = path.rsplit('.', 1) 309 | return getattr(__import__(module, {}, {}, [classname]), classname) 310 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, Client 2 | from django.conf import settings as django_settings 3 | from django.test.utils import ContextList 4 | from django.test.signals import template_rendered 5 | from django import template 6 | from django.db.models.signals import pre_save 7 | 8 | from django.contrib import admin 9 | from django.contrib.auth.models import User 10 | 11 | from adminfiles import settings, parse 12 | from adminfiles.utils import render_uploads 13 | from adminfiles.models import FileUpload, FileUploadReference 14 | from adminfiles.listeners import _register_upload_listener, \ 15 | _disconnect_upload_listener 16 | from adminfiles.admin import FilePickerAdmin 17 | 18 | from models import Post 19 | 20 | class PostAdmin(FilePickerAdmin): 21 | adminfiles_fields = ('content',) 22 | 23 | class FileUploadTestCase(TestCase): 24 | """ 25 | Test case with a populate() method to save a couple FileUpload instances. 26 | 27 | """ 28 | def populate(self): 29 | self.animage = FileUpload.objects.create( 30 | upload='adminfiles/tiny.png', 31 | title='An image', 32 | slug='an-image') 33 | self.somefile = FileUpload.objects.create( 34 | upload='adminfiles/somefile.txt', 35 | title='Some file', 36 | slug='some-file') 37 | 38 | class FilePickerTests(FileUploadTestCase): 39 | def setUp(self): 40 | self.populate() 41 | self.client = Client() 42 | admin.site.register(Post, PostAdmin) 43 | self.admin = User.objects.create_user('admin', 'admin@example.com', 44 | 'testpw') 45 | self.admin.is_staff = True 46 | self.admin.is_superuser = True 47 | self.admin.is_active = True 48 | self.admin.save() 49 | self.assertTrue(self.client.login(username='admin', password='testpw')) 50 | 51 | def tearDown(self): 52 | admin.site.unregister(Post) 53 | 54 | def test_picker_class_applied(self): 55 | response = self.client.get('/admin/tests/post/add/') 56 | self.assertContains(response, 'class="vLargeTextField adminfilespicker"') 57 | 58 | def test_picker_loads(self): 59 | """ 60 | Very basic smoke test for file picker. 61 | 62 | """ 63 | response = self.client.get('/adminfiles/all/?field=test') 64 | self.assertContains(response, 'href="/media/adminfiles/tiny.png"') 65 | self.assertContains(response, 'href="/media/adminfiles/somefile.txt') 66 | 67 | def test_browser_links(self): 68 | """ 69 | Test correct rendering of browser links. 70 | 71 | """ 72 | response = self.client.get('/adminfiles/all/?field=test') 73 | self.assertContains(response, 'href="/adminfiles/images/?field=test') 74 | 75 | def test_images_picker_loads(self): 76 | response = self.client.get('/adminfiles/images/?field=test') 77 | self.assertContains(response, 'href="/media/adminfiles/tiny.png"') 78 | self.assertNotContains(response, 'href="/media/adminfiles/somefile.txt') 79 | 80 | def test_files_picker_loads(self): 81 | response = self.client.get('/adminfiles/files/?field=test') 82 | self.assertNotContains(response, 'href="/media/adminfiles/tiny.png"') 83 | self.assertContains(response, 'href="/media/adminfiles/somefile.txt') 84 | 85 | def test_custom_links(self): 86 | _old_links = settings.ADMINFILES_INSERT_LINKS.copy() 87 | settings.ADMINFILES_INSERT_LINKS['text/plain'] = [('Crazy insert', {'yo': 'thing'})] 88 | 89 | response = self.client.get('/adminfiles/all/?field=test') 90 | self.assertContains(response, 'rel="some-file:yo=thing"') 91 | 92 | settings.ADMINFILES_INSERT_LINKS = _old_links 93 | 94 | def test_thumb_order(self): 95 | _old_order = settings.ADMINFILES_THUMB_ORDER 96 | settings.ADMINFILES_THUMB_ORDER = ('title',) 97 | 98 | response = self.client.get('/adminfiles/all/?field=test') 99 | image_index = response.content.find('tiny.png') 100 | file_index = response.content.find('somefile.txt') 101 | self.assertTrue(image_index > 0) 102 | self.assertTrue(image_index < file_index) 103 | 104 | settings.ADMINFILES_THUMB_ORDER = _old_order 105 | 106 | class SignalTests(FileUploadTestCase): 107 | """ 108 | Test tracking of uploaded file references, and auto-resave of 109 | content models when referenced uploaded file changes. 110 | 111 | """ 112 | def setUp(self): 113 | self._old_use_signals = settings.ADMINFILES_USE_SIGNALS 114 | settings.ADMINFILES_USE_SIGNALS = True 115 | if not self._old_use_signals: 116 | _register_upload_listener() 117 | 118 | PostAdmin(Post, admin.site) 119 | 120 | self.populate() 121 | 122 | def tearDown(self): 123 | if not self._old_use_signals: 124 | _disconnect_upload_listener() 125 | settings.ADMINFILES_USE_SIGNALS = self._old_use_signals 126 | 127 | def test_track_references(self): 128 | Post.objects.create(title='Some title', 129 | content='This has a reference to' 130 | '<<>>') 131 | 132 | self.assertEquals(FileUploadReference.objects.count(), 1) 133 | 134 | def test_track_multiple_references(self): 135 | Post.objects.create(title='Some title', 136 | content='This has a reference to' 137 | '<<>> and <<>>') 138 | 139 | self.assertEquals(FileUploadReference.objects.count(), 2) 140 | 141 | def test_track_no_dupe_references(self): 142 | post = Post.objects.create(title='Some title', 143 | content='This has a reference to' 144 | '<<>> and <<>>') 145 | 146 | post.save() 147 | 148 | self.assertEquals(FileUploadReference.objects.count(), 1) 149 | 150 | def test_update_reference(self): 151 | post = Post.objects.create(title='Some title', 152 | content='This has a reference to' 153 | '<<>>') 154 | 155 | def _render_on_save(sender, instance, **kwargs): 156 | instance.content = render_uploads(instance.content) 157 | pre_save.connect(_render_on_save, sender=Post) 158 | 159 | self.somefile.title = 'A New Title' 160 | self.somefile.save() 161 | 162 | reloaded_post = Post.objects.get(title='Some title') 163 | 164 | self.assertTrue('A New Title' in reloaded_post.content) 165 | 166 | class TemplateTestCase(TestCase): 167 | """ 168 | A TestCase that stores information about rendered templates, much 169 | like the Django test client. 170 | 171 | """ 172 | def store_rendered_template(self, signal, sender, template, context, 173 | **kwargs): 174 | self.templates.append(template) 175 | self.contexts.append(context) 176 | 177 | def setUp(self): 178 | self.templates = [] 179 | self.contexts = ContextList() 180 | template_rendered.connect(self.store_rendered_template) 181 | 182 | def tearDown(self): 183 | template_rendered.disconnect(self.store_rendered_template) 184 | 185 | class RenderTests(TemplateTestCase, FileUploadTestCase): 186 | """ 187 | Test rendering of uploaded file references. 188 | 189 | """ 190 | def setUp(self): 191 | super(RenderTests, self).setUp() 192 | self.populate() 193 | 194 | def test_render_template_used(self): 195 | render_uploads('<<>>') 196 | self.assertEquals(self.templates[0].name, 197 | 'adminfiles/render/default.html') 198 | 199 | def test_render_mimetype_template_used(self): 200 | render_uploads('<<>>') 201 | self.assertEquals(self.templates[0].name, 202 | 'adminfiles/render/image/default.html') 203 | 204 | def test_render_subtype_template_used(self): 205 | render_uploads('<<>>', 'alt') 206 | self.assertEquals(self.templates[0].name, 207 | 'alt/image/png.html') 208 | 209 | def test_render_whitespace(self): 210 | render_uploads('<<< some-file \n>>>') 211 | self.assertEquals(len(self.templates), 1) 212 | 213 | def test_render_amidst_content(self): 214 | render_uploads('Some test here<<< some-file \n>>>and more here') 215 | self.assertEquals(len(self.templates), 1) 216 | 217 | def test_render_upload_in_context(self): 218 | render_uploads('<<>>') 219 | self.assertEquals(self.contexts['upload'].upload.name, 220 | 'adminfiles/somefile.txt') 221 | 222 | def test_render_options_in_context(self): 223 | render_uploads('<<>>') 224 | self.assertEquals(self.contexts['options'], {'class': 'left', 225 | 'key': 'val'}) 226 | 227 | def test_render_alternate_markers(self): 228 | old_start = settings.ADMINFILES_REF_START 229 | old_end = settings.ADMINFILES_REF_END 230 | settings.ADMINFILES_REF_START = '[[[' 231 | settings.ADMINFILES_REF_END = ']]]' 232 | parse.UPLOAD_RE = parse._get_upload_re() 233 | 234 | render_uploads('[[[some-file]]]') 235 | self.assertEquals(len(self.templates), 1) 236 | 237 | settings.ADMINFILES_REF_START = old_start 238 | settings.ADMINFILES_REF_END = old_end 239 | parse.UPLOAD_RE = parse._get_upload_re() 240 | 241 | def test_render_invalid(self): 242 | old_nf = settings.ADMINFILES_STRING_IF_NOT_FOUND 243 | settings.ADMINFILES_STRING_IF_NOT_FOUND = u'not found' 244 | 245 | html = render_uploads('<<>>') 246 | self.assertEquals(html, u'not found') 247 | 248 | settings.ADMINFILES_STRING_IF_NOT_FOUND = old_nf 249 | 250 | def test_default_template_renders_image(self): 251 | html = render_uploads('<<>>') 252 | self.assertTrue('>>') 256 | self.assertTrue('class="some classes"' in html) 257 | 258 | def test_default_template_renders_image_alt(self): 259 | html = render_uploads('<<>>') 260 | self.assertTrue('alt="the alt text"' in html) 261 | 262 | def test_default_template_renders_image_title_as_alt(self): 263 | html = render_uploads('<<>>') 264 | self.assertTrue('alt="An image"' in html) 265 | 266 | def test_default_template_renders_link(self): 267 | html = render_uploads('<<>>') 268 | self.assertTrue('>>') 272 | self.assertTrue('class="other classes"' in html) 273 | 274 | def test_default_template_renders_link_title(self): 275 | html = render_uploads('<<>>') 276 | self.assertTrue('Some file' in html) 277 | 278 | def test_default_template_renders_link_title(self): 279 | html = render_uploads('<<>>') 280 | self.assertTrue('Other name' in html) 281 | 282 | def test_template_override(self): 283 | html = render_uploads('<<>>') 284 | self.assertTrue('>>') 288 | self.assertTrue('>>') 292 | self.assertTrue('>>')})) 300 | self.assertEquals(self.templates[1].name, 301 | 'adminfiles/render/default.html') 302 | self.assertTrue('>>')})) 311 | self.assertEquals(self.templates[1].name, 'alt/default.html') 312 | 313 | def test_render_upload_template_filter(self): 314 | tpl = template.Template(u'{% load adminfiles_tags %}' 315 | u'{{ img|render_upload }}') 316 | html = tpl.render(template.Context({'img': self.animage})) 317 | self.assertEquals(self.templates[1].name, 318 | 'adminfiles/render/image/default.html') 319 | self.assertTrue(' tags (with additional markup 14 | as needed) for images, links for downloadable files, even embedded 15 | players for audio or video files. See `the screencast`_. 16 | 17 | .. _the screencast: http://vimeo.com/8940852 18 | 19 | Installation 20 | ============ 21 | 22 | Install from PyPI with ``easy_install`` or ``pip``:: 23 | 24 | pip install django-adminfiles 25 | 26 | or get the in-development version:: 27 | 28 | pip install http://bitbucket.org/carljm/django-adminfiles/get/tip.gz 29 | 30 | 31 | Dependencies 32 | ------------ 33 | 34 | ``django-adminfiles`` requires `Django`_ 1.4 or later, 35 | `sorl-thumbnail`_ 11.12 (not compatible with old 3.x series) 36 | and the `Python Imaging Library`_. 37 | 38 | `djangoembed`_ or `django-oembed`_ is required for OEmbed 39 | functionality. `flickrapi`_ is required for browsing Flickr photos, `gdata`_ 40 | for Youtube videos. 41 | 42 | .. _Django: http://www.djangoproject.com/ 43 | .. _sorl-thumbnail: http://pypi.python.org/pypi/sorl-thumbnail/11.12 44 | .. _Python Imaging Library: http://www.pythonware.com/products/pil/ 45 | .. _django-oembed: http://pypi.python.org/pypi/django-oembed 46 | .. _djangoembed: http://pypi.python.org/pypi/djangoembed 47 | .. _gdata: http://pypi.python.org/pypi/gdata 48 | .. _flickrapi: http://pypi.python.org/pypi/flickrapi 49 | 50 | Usage 51 | ===== 52 | 53 | To use django-adminfiles in your Django project: 54 | 55 | 1. Add ``'adminfiles'`` to your ``INSTALLED_APPS`` setting. Also 56 | add ``'sorl.thumbnail'`` if you have not installed it already. 57 | 58 | 2. Run ``python manage.py syncdb`` to to create the adminfiles database 59 | tables. 60 | 61 | 3. Make the contents of the ``adminfiles/static/adminfiles`` directory 62 | available at ``STATIC_URL/adminfiles``. This can be done by through 63 | your webserver configuration, via an app such as 64 | ``django.contrib.staticfiles``, or by copying the files or making a 65 | symlink. 66 | 67 | 4. Add ``url(r'^adminfiles/', include('adminfiles.urls'))`` in your 68 | root URLconf. 69 | 70 | 5. Inherit content model admin options from 71 | `FilePickerAdmin`_. 72 | 73 | In addition, you may want to set the ``THUMBNAIL_EXTENSION`` setting for 74 | `sorl-thumbnail`_ to ``"png"`` rather than the default ``"jpg"``, so that 75 | images with alpha transparency aren't broken when thumbnailed in the 76 | adminfiles file-picker. 77 | 78 | 79 | FilePickerAdmin 80 | =============== 81 | 82 | For each model you'd like to use the ``django-adminfiles`` picker 83 | with, inherit that model's admin options class from 84 | ``adminfiles.admin.FilePickerAdmin`` instead of the usual 85 | ``django.contrib.admin.ModelAdmin``, and set the ``adminfiles_fields`` 86 | attribute to a list/tuple of the names of the content fields it is 87 | used with. 88 | 89 | For instance, if you have a ``Post`` model with a ``content`` 90 | TextField, and you'd like to insert references into that TextField 91 | from a ``django-adminfiles`` picker:: 92 | 93 | from django.contrib import admin 94 | 95 | from adminfiles.admin import FilePickerAdmin 96 | 97 | from myapp.models import Post 98 | 99 | class PostAdmin(FilePickerAdmin): 100 | adminfiles_fields = ('content',) 101 | 102 | admin.site.register(Post, PostAdmin) 103 | 104 | The picker displays thumbnails of all uploaded images, and appropriate 105 | icons for non-image files. It also allows you to filter and view only 106 | images or only non-image files. In the lower left it contains links to 107 | upload a new file or refresh the list of available files. 108 | 109 | If you click on a file thumbnail/icon, a menu pops up with options to 110 | edit or delete the uploaded file, or insert it into the associated 111 | content field. To modify the default insertion options, set the 112 | `ADMINFILES_INSERT_LINKS`_ setting. 113 | 114 | File references 115 | =============== 116 | 117 | When you use the file upload picker to insert an uploaded file 118 | reference in a text content field, it inserts something like 119 | ``<<>>``, built from the `ADMINFILES_REF_START`_ and 120 | `ADMINFILES_REF_END`_ settings and the slug of the ``FileUpload`` 121 | instance. 122 | 123 | The reference can also contain arbitrary key=value option after the 124 | file slug, separated by colons, e.g.: 125 | ``<<>>``. 126 | 127 | These generic references allow you to use ``django-adminfiles`` with 128 | raw HTML content or any type of text markup. They also allow you to 129 | change uploaded files and have old references to the file pick up the 130 | change (as long as the slug does not change). The URL path to the 131 | file, or other metadata like the height or width of an image, are not 132 | hardcoded in your content. 133 | 134 | Rendering references 135 | -------------------- 136 | 137 | These references need to be rendered at some point into whatever 138 | markup you ultimately want. The markup produced by the rendering is 139 | controlled by the Django templates under ``adminfiles/render/``. 140 | 141 | The template used is selected according to the mime type of the file 142 | upload referenced. For instance, for rendering a file with mime type 143 | ``image/jpeg``, the template used would be the first template of the 144 | following that exists: ``adminfiles/render/image/jpeg.html``, 145 | ``adminfiles/render/image/default.html``, 146 | ``adminfiles/render/default.html``. 147 | 148 | If a file should be rendered as if it had a different mime type 149 | (e.g. an image you want to link to rather than display), pass the 150 | ``as`` option with the mime type you want it rendered as (where either 151 | the sub-type or the entire mime-type can be replaced with 152 | ``default``). For instance, with the default available templates if 153 | you wanted to link to an image file, you could use 154 | ``<<>>``. 155 | 156 | Two rendering templates are included with ``django-adminfiles``: 157 | ``adminfiles/render/image/default.html`` (used for any type of image) 158 | and ``adminfiles/render/default.html`` (used for any other type of 159 | file). These default templates produce an HTML ``img`` tag for images 160 | and a simple ``a`` link to other file types. They also respect three 161 | key-value options: ``class``, which will be used as the the ``class`` 162 | attribute of the ``img`` or ``a`` tag; ``alt``, which will be the 163 | image alt text (images only; if not provided ``upload.title`` is used 164 | for alt text); and ``title``, which will override ``upload.title`` as 165 | the link text of the ``a`` tag (non-images only). 166 | 167 | You can easily override these templates with your own, and provide 168 | additional templates for other file types. The template is rendered 169 | with the following context: 170 | 171 | ``upload`` 172 | The ``FileUpload`` model instance whose slug field matches the 173 | reference. Useful attributes of this instance include 174 | ``upload.upload`` (a `Django File object`_), ``upload.title``, 175 | ``upload.description``, ``upload.mime_type`` (first and second 176 | parts separately accessible as ``upload.content_type`` and 177 | ``upload.sub_type``) and ``upload.is_image`` (True if 178 | ``upload.content_type`` is "image"). Images also have 179 | ``upload.height`` and ``upload.width`` available. 180 | 181 | ``options`` 182 | A dictionary of the key=value options in the reference. 183 | 184 | If a reference is encountered with an invalid slug (no ``FileUpload`` 185 | found in the database with that slug), the value of the 186 | `ADMINFILES_STRING_IF_NOT_FOUND`_ setting is rendered instead 187 | (defaults to the empty string). 188 | 189 | .. _Django File object: http://docs.djangoproject.com/en/dev/ref/files/file/ 190 | 191 | render_uploads template filter 192 | ------------------------------ 193 | 194 | ``django-adminfiles`` provides two methods for making the actual 195 | rendering happen. The simple method is a template filter: 196 | ``render_uploads``. To use it, just load the ``adminfiles_tags`` tag 197 | library, and apply the ``render_uploads`` filter to your content field:: 198 | 199 | {% load adminfiles_tags %} 200 | 201 | {{ post.content|render_uploads }} 202 | 203 | The ``render_uploads`` filter just replaces any file upload references 204 | in the content with the rendered template (described above). 205 | 206 | The filter also accepts an optional argument: an alternate base path 207 | to the templates to use for rendering each uploaded file 208 | reference. This path will replace ``adminfiles/render`` as the base 209 | path in the mime-type-based search for specific templates. This allows 210 | different renderings to be used in different circumstances:: 211 | 212 | {{ post.content|render_uploads:"adminfiles/alt_render" }} 213 | 214 | For a file of mime type ``text/plain`` this would use one of the 215 | following templates: ``adminfiles/alt_render/text/plain.html``, 216 | ``adminfiles/alt_render/text/default.html``, or 217 | ``adminfiles/alt_render/default.html``. 218 | 219 | render_upload template filter 220 | ----------------------------- 221 | 222 | If you have a ``FileUpload`` model instance in your template and wish 223 | to render just that instance using the normal rendering logic, you can 224 | use the ``render_upload`` filter. This filter accepts options in the 225 | same "key=val:key2=val2" format used for passing options to 226 | inline-embedded files; the special option ``template_path`` specifies 227 | an alternate base path for finding rendering templates:: 228 | 229 | {{ my_upload|render_upload:"template_path=adminfiles/alt_render:class=special" }} 230 | 231 | pre-rendering at save time 232 | -------------------------- 233 | 234 | In some cases, markup in content fields is pre-rendered when the model 235 | is saved, and stored in the database or cache. In this case, it may be 236 | preferable to also render the uploaded file references in that step, 237 | rather than re-rendering them every time the content is displayed in 238 | the template. 239 | 240 | To use this approach, first you need to integrate the function 241 | ``adminfiles.utils.render_uploads`` into your existing content 242 | pre-rendering process, which should be automatically triggered by 243 | saving the content model. 244 | 245 | The ``adminfiles.utils.render_uploads`` function takes a content 246 | string as its argument and returns the same string with all uploaded 247 | file references replaced, same as the template tag. It also accepts a 248 | ``template_path`` argument, which is the same as the argument accepted 249 | by the `render_uploads template filter`_. 250 | 251 | Integrating this function in the markup-rendering step is outside the 252 | scope of ``django-adminfiles``. For instance, if using 253 | `django-markitup`_ with Markdown to process content markup, the 254 | ``MARKITUP_FILTER`` setting might look like this:: 255 | 256 | MARKITUP_FILTER = ("utils.markup_filter", {}) 257 | 258 | Which points to a function in ``utils.py`` like this:: 259 | 260 | from markdown import markdown 261 | from adminfiles.utils import render_uploads 262 | 263 | def markup_filter(markup): 264 | return markdown(render_uploads(markup)) 265 | 266 | Once this is done, set the `ADMINFILES_USE_SIGNALS`_ setting to 267 | True. Now ``django-adminfiles`` will automatically track all 268 | references to uploaded files in your content models. Anytime an 269 | uploaded file is changed, all content models which reference it will 270 | automatically be re-saved (and thus updated with the new uploaded 271 | file). 272 | 273 | .. _django-markitup: http://bitbucket.org/carljm/django-markitup 274 | 275 | Embedding media from other sites 276 | ================================ 277 | 278 | ``django-adminfiles`` allows embedding media from any site that supports the 279 | OEmbed protocol. OEmbed support is provided via `djangoembed`_ or 280 | `django-oembed`_, one of which must be installed for embedding to work. 281 | 282 | If a supported OEmbed application is installed, the `render_uploads template 283 | filter`_ will also automatically replace any OEmbed-capable URLs with the 284 | appropriate embed markup (so URLs from any site supported by the installed 285 | OEmbed application can simply be pasted in to the content manually). 286 | 287 | In addition, ``django-adminfiles`` provides views in its filepicker to 288 | browse Flickr photos, Youtube videos, and Vimeo videos and insert 289 | their URLs into the context textarea with a click. To enable these 290 | browsing views, set the `ADMINFILES_YOUTUBE_USER`_, 291 | `ADMINFILES_VIMEO_USER`_, or `ADMINFILES_FLICKR_USER`_ and 292 | `ADMINFILES_FLICKR_API_KEY`_ settings (and make sure the 293 | `dependencies`_ are satisfied). 294 | 295 | To add support for browsing content from another site, just create a 296 | class view that inherits from ``adminfiles.views.OEmbedView`` and add 297 | its dotted path to the `ADMINFILES_BROWSER_VIEWS`_ setting. See the 298 | existing views in ``adminfiles/views.py`` for details. 299 | 300 | To list the available browsing views and their status (enabled or 301 | disabled, and why), ``django-adminfiles`` provides an 302 | ``adminfiles_browser_views`` management command, which you can run 303 | with ``./manage.py adminfiles_browser_views``. 304 | 305 | Settings 306 | ======== 307 | 308 | ADMINFILES_REF_START 309 | -------------------- 310 | 311 | Marker indicating the beginning of an uploaded-file reference in text 312 | content. Defaults to '<<<'. 313 | 314 | If you set this to something insufficiently distinctive (a string 315 | that's likely to show up otherwise in your content), all bets are off. 316 | 317 | Special regex characters are escaped, thus you can safely set it to 318 | something like '[[[', but you can't do advanced regex magic with it. 319 | 320 | ADMINFILES_REF_END 321 | ------------------ 322 | 323 | Marker indicating the end of an uploaded-file reference in text 324 | content. Defaults to '>>>'. 325 | 326 | If you set this to something insufficiently distinctive (a string 327 | that's likely to show up otherwise in your content), all bets are off. 328 | 329 | Special regex characters are escaped, thus you can safely set it to 330 | something like ']]]', but you can't do advanced regex magic with it. 331 | 332 | ADMINFILES_USE_SIGNALS 333 | ---------------------- 334 | 335 | A boolean setting: should ``django-adminfiles`` track which content 336 | models reference which uploaded files, and re-save those content 337 | models whenever a referenced uploaded file changes? 338 | 339 | Set this to True if you already pre-render markup in content fields at 340 | save time and want to render upload references at that same save-time 341 | pre-rendering step. 342 | 343 | Defaults to False. If this setting doesn't make sense to you, you can 344 | safely just leave it False and use the `render_uploads template 345 | filter`_. 346 | 347 | ADMINFILES_STRING_IF_NOT_FOUND 348 | ------------------------------ 349 | 350 | The string used to replace invalid uploaded file references (given 351 | slug not found). Defaults to ``u''``. 352 | 353 | ADMINFILES_STDICON_SET 354 | ---------------------- 355 | 356 | Django-adminfiles ships with a few icons for common file types, used 357 | for displaying non-image files in the file-picker. To enable a broader 358 | range of mime-type icons, set this setting to the name of an icon set 359 | included at `stdicon.com`_, and icons from that set will be linked. 360 | 361 | .. _stdicon.com: http://www.stdicon.com 362 | 363 | ADMINFILES_INSERT_LINKS 364 | ----------------------- 365 | 366 | By default, the admin file picker popup menu for images allows 367 | inserting a reference with no options, a reference with "class=left", 368 | or a reference with "class=right". For non-images, the default popup 369 | menu only allows inserting a reference without options. To change the 370 | insertion options for various file types, set 371 | ``ADMINFILES_INSERT_LINKS`` to a dictionary mapping mime-types (or 372 | partial mime-types) to a list of insertion menu options. For instance, 373 | the default setting looks like this:: 374 | 375 | ADMINFILES_INSERT_LINKS = { 376 | '': [('Insert Link', {})], 377 | 'image': [('Insert', {}), 378 | ('Insert (left)', {'class': 'left'}), 379 | ('Insert (right)', {'class': 'right'})] 380 | } 381 | 382 | Each key in the dictionary can be the first segment of a mime type 383 | (e.g. "image"), or a full mime type (e.g. "audio/mpeg"), or an empty 384 | string (the default used if no mime type matches). For any given file 385 | the most specific matching entry is used. The dictionary should always 386 | contain a default entry (empty string key), or some files may have no 387 | insertion options. 388 | 389 | Each value in the dictionary is a list of menu items. Each menu item 390 | is a two-tuple, where the first entry is the user-visible name for the 391 | insertion option, and the second entry is a dictionary of options to 392 | be added to the inserted file reference. 393 | 394 | 395 | ADMINFILES_UPLOAD_TO 396 | -------------------- 397 | 398 | The ``upload_to`` argument that will be passed to the ``FileField`` on 399 | ``django-admin-upload``'s ``FileUpload`` model; determines where 400 | ``django-adminfiles`` keeps its uploaded files, relative to 401 | ``MEDIA_URL``. Can include strftime formatting codes as described `in 402 | the Django documentation`_. By default, set to ``'adminfiles'``. 403 | 404 | .. _in the Django documentation: http://docs.djangoproject.com/en/dev/ref/models/fields/#django.db.models.FileField.upload_to 405 | 406 | ADMINFILES_THUMB_ORDER 407 | ---------------------- 408 | 409 | The ordering that will be applied to thumbnails displayed in the 410 | picker. Expects a tuple of field names, prefixed with ``-`` to 411 | indicate reverse ordering, same as `"ordering" model Meta 412 | attribute`_. The default value is ``('-upload_date')``; thumbnails 413 | ordered by date uploaded, most recent first. 414 | 415 | .. _"ordering" model Meta attribute: http://docs.djangoproject.com/en/dev/ref/models/options/#ordering 416 | 417 | ADMINFILES_BROWSER_VIEWS 418 | ------------------------ 419 | 420 | List of dotted paths to file-browsing views to make available in the 421 | filepicker. The default setting includes all the views bundled with 422 | ``django-adminfiles``:: 423 | 424 | ['adminfiles.views.AllView', 425 | 'adminfiles.views.ImagesView', 426 | 'adminfiles.views.AudioView', 427 | 'adminfiles.views.FilesView', 428 | 'adminfiles.views.FlickrView', 429 | 'adminfiles.views.YouTubeView', 430 | 'adminfiles.views.VimeoView'] 431 | 432 | The last three may be disabled despite their inclusion in this setting 433 | if their `dependencies`_ are not satisfied or their required settings 434 | are not set. 435 | 436 | ADMINFILES_YOUTUBE_USER 437 | ----------------------- 438 | 439 | Required for use of the Youtube video browser. 440 | 441 | ADMINFILES_VIMEO_USER 442 | --------------------- 443 | 444 | Required for use of the Vimeo video browser. 445 | 446 | ADMINFILES_VIMEO_PAGES 447 | ---------------------- 448 | 449 | The Vimeo API returns 20 videos per page; this setting determines the 450 | maximum number of pages to fetch (defaults to 1, Vimeo-imposed maximum of 451 | 3). 452 | 453 | ADMINFILES_FLICKR_USER 454 | ---------------------- 455 | 456 | Required for use of the Flickr photo browser. 457 | 458 | ADMINFILES_FLICKR_API_KEY 459 | ------------------------- 460 | 461 | Required for use of the Flickr photo browser. 462 | 463 | JQUERY_URL 464 | ---------- 465 | 466 | ``django-adminfiles`` requires the jQuery Javascript library. By default, 467 | ``django-adminfiles`` uses the latest version of jQuery 1.4 hosted by Google, 468 | via the URL http://ajax.googleapis.com/ajax/libs/jquery/1.4/jquery.min.js. 469 | 470 | If you wish to use a different version of jQuery, or host it yourself, set the 471 | ``JQUERY_URL`` setting. ``JQUERY_URL`` can be absolute or relative; if relative 472 | it is relative to ``STATIC_URL``. For example:: 473 | 474 | JQUERY_URL = 'jquery.min.js' 475 | 476 | This will use the jQuery available at ``STATIC_URL/jquery.min.js``. 477 | -------------------------------------------------------------------------------- /adminfiles/flickr.py: -------------------------------------------------------------------------------- 1 | """ 2 | flickr.py 3 | Copyright 2004-6 James Clarke 4 | 5 | THIS SOFTWARE IS SUPPLIED WITHOUT WARRANTY OF ANY KIND, AND MAY BE 6 | COPIED, MODIFIED OR DISTRIBUTED IN ANY WAY, AS LONG AS THIS NOTICE 7 | AND ACKNOWLEDGEMENT OF AUTHORSHIP REMAIN. 8 | 9 | 2006-12-19 10 | Applied patches from Berco Beute and Wolfram Kriesing. 11 | TODO list below is out of date! 12 | 13 | 2005-06-10 14 | TOOD list: 15 | * flickr.blogs.* 16 | * flickr.contacts.getList 17 | * flickr.groups.browse 18 | * flickr.groups.getActiveList 19 | * flickr.people.getOnlineList 20 | * flickr.photos.getContactsPhotos 21 | * flickr.photos.getContactsPublicPhotos 22 | * flickr.photos.getContext 23 | * flickr.photos.getCounts 24 | * flickr.photos.getExif 25 | * flickr.photos.getNotInSet 26 | * flickr.photos.getPerms 27 | * flickr.photos.getRecent 28 | * flickr.photos.getUntagged 29 | * flickr.photos.setDates 30 | * flickr.photos.setPerms 31 | * flickr.photos.licenses.* 32 | * flickr.photos.notes.* 33 | * flickr.photos.transform.* 34 | * flickr.photosets.getContext 35 | * flickr.photosets.orderSets 36 | * flickr.reflection.* (not important) 37 | * flickr.tags.getListPhoto 38 | * flickr.urls.* 39 | """ 40 | 41 | __author__ = "James Clarke " 42 | __version__ = "$Rev$" 43 | __date__ = "$Date$" 44 | __copyright__ = "Copyright 2004-6 James Clarke" 45 | 46 | from urllib import urlencode, urlopen 47 | from xml.dom import minidom 48 | 49 | HOST = 'http://flickr.com' 50 | API = '/services/rest' 51 | 52 | #set these here or using flickr.API_KEY in your application 53 | API_KEY = '' 54 | email = None 55 | password = None 56 | 57 | class FlickrError(Exception): pass 58 | 59 | class Photo(object): 60 | """Represents a Flickr Photo.""" 61 | 62 | __readonly = ['id', 'secret', 'server', 'isfavorite', 'license', 'rotation', 63 | 'owner', 'dateposted', 'datetaken', 'takengranularity', 64 | 'title', 'description', 'ispublic', 'isfriend', 'isfamily', 65 | 'cancomment', 'canaddmeta', 'comments', 'tags', 'permcomment', 66 | 'permaddmeta'] 67 | 68 | #XXX: Hopefully None won't cause problems 69 | def __init__(self, id, owner=None, dateuploaded=None, \ 70 | title=None, description=None, ispublic=None, \ 71 | isfriend=None, isfamily=None, cancomment=None, \ 72 | canaddmeta=None, comments=None, tags=None, secret=None, \ 73 | isfavorite=None, server=None, license=None, rotation=None): 74 | """Must specify id, rest is optional.""" 75 | self.__loaded = False 76 | self.__cancomment = cancomment 77 | self.__canaddmeta = canaddmeta 78 | self.__comments = comments 79 | self.__dateuploaded = dateuploaded 80 | self.__description = description 81 | self.__id = id 82 | self.__license = license 83 | self.__isfamily = isfamily 84 | self.__isfavorite = isfavorite 85 | self.__isfriend = isfriend 86 | self.__ispublic = ispublic 87 | self.__owner = owner 88 | self.__rotation = rotation 89 | self.__secret = secret 90 | self.__server = server 91 | self.__tags = tags 92 | self.__title = title 93 | 94 | self.__dateposted = None 95 | self.__datetaken = None 96 | self.__takengranularity = None 97 | self.__permcomment = None 98 | self.__permaddmeta = None 99 | 100 | def __setattr__(self, key, value): 101 | if key in self.__class__.__readonly: 102 | raise AttributeError("The attribute %s is read-only." % key) 103 | else: 104 | super(Photo, self).__setattr__(key, value) 105 | 106 | def __getattr__(self, key): 107 | if not self.__loaded: 108 | self._load_properties() 109 | if key in self.__class__.__readonly: 110 | return super(Photo, self).__getattribute__("_%s__%s" % (self.__class__.__name__, key)) 111 | else: 112 | return super(Photo, self).__getattribute__(key) 113 | 114 | def _load_properties(self): 115 | """Loads the properties from Flickr.""" 116 | self.__loaded = True 117 | 118 | method = 'flickr.photos.getInfo' 119 | data = _doget(method, photo_id=self.id) 120 | 121 | photo = data.rsp.photo 122 | 123 | self.__secret = photo.secret 124 | self.__server = photo.server 125 | self.__isfavorite = photo.isfavorite 126 | self.__license = photo.license 127 | self.__rotation = photo.rotation 128 | 129 | 130 | 131 | owner = photo.owner 132 | self.__owner = User(owner.nsid, username=owner.username,\ 133 | realname=owner.realname,\ 134 | location=owner.location) 135 | 136 | self.__title = photo.title.text 137 | self.__description = photo.description.text 138 | self.__ispublic = photo.visibility.ispublic 139 | self.__isfriend = photo.visibility.isfriend 140 | self.__isfamily = photo.visibility.isfamily 141 | 142 | self.__dateposted = photo.dates.posted 143 | self.__datetaken = photo.dates.taken 144 | self.__takengranularity = photo.dates.takengranularity 145 | 146 | self.__cancomment = photo.editability.cancomment 147 | self.__canaddmeta = photo.editability.canaddmeta 148 | self.__comments = photo.comments.text 149 | 150 | try: 151 | self.__permcomment = photo.permissions.permcomment 152 | self.__permaddmeta = photo.permissions.permaddmeta 153 | except AttributeError: 154 | self.__permcomment = None 155 | self.__permaddmeta = None 156 | 157 | #TODO: Implement Notes? 158 | if hasattr(photo.tags, "tag"): 159 | if isinstance(photo.tags.tag, list): 160 | self.__tags = [Tag(tag.id, User(tag.author), tag.raw, tag.text) \ 161 | for tag in photo.tags.tag] 162 | else: 163 | tag = photo.tags.tag 164 | self.__tags = [Tag(tag.id, User(tag.author), tag.raw, tag.text)] 165 | 166 | 167 | def __str__(self): 168 | return '' % self.id 169 | 170 | 171 | def setTags(self, tags): 172 | """Set the tags for current photo to list tags. 173 | (flickr.photos.settags) 174 | """ 175 | method = 'flickr.photos.setTags' 176 | tags = uniq(tags) 177 | _dopost(method, auth=True, photo_id=self.id, tags=tags) 178 | self._load_properties() 179 | 180 | 181 | def addTags(self, tags): 182 | """Adds the list of tags to current tags. (flickr.photos.addtags) 183 | """ 184 | method = 'flickr.photos.addTags' 185 | if isinstance(tags, list): 186 | tags = uniq(tags) 187 | 188 | _dopost(method, auth=True, photo_id=self.id, tags=tags) 189 | #load properties again 190 | self._load_properties() 191 | 192 | def removeTag(self, tag): 193 | """Remove the tag from the photo must be a Tag object. 194 | (flickr.photos.removeTag) 195 | """ 196 | method = 'flickr.photos.removeTag' 197 | tag_id = '' 198 | try: 199 | tag_id = tag.id 200 | except AttributeError: 201 | raise FlickrError, "Tag object expected" 202 | _dopost(method, auth=True, photo_id=self.id, tag_id=tag_id) 203 | self._load_properties() 204 | 205 | 206 | def setMeta(self, title=None, description=None): 207 | """Set metadata for photo. (flickr.photos.setMeta)""" 208 | method = 'flickr.photos.setMeta' 209 | 210 | if title is None: 211 | title = self.title 212 | if description is None: 213 | description = self.description 214 | 215 | _dopost(method, auth=True, title=title, \ 216 | description=description, photo_id=self.id) 217 | 218 | self.__title = title 219 | self.__description = description 220 | 221 | 222 | def getURL(self, size='Medium', urlType='url'): 223 | """Retrieves a url for the photo. (flickr.photos.getSizes) 224 | 225 | urlType - 'url' or 'source' 226 | 'url' - flickr page of photo 227 | 'source' - image file 228 | """ 229 | method = 'flickr.photos.getSizes' 230 | data = _doget(method, photo_id=self.id) 231 | for psize in data.rsp.sizes.size: 232 | if psize.label == size: 233 | return getattr(psize, urlType) 234 | raise FlickrError, "No URL found" 235 | 236 | def getSizes(self): 237 | """ 238 | Get all the available sizes of the current image, and all available 239 | data about them. 240 | Returns: A list of dicts with the size data. 241 | """ 242 | method = 'flickr.photos.getSizes' 243 | data = _doget(method, photo_id=self.id) 244 | ret = [] 245 | # The given props are those that we return and the according types, since 246 | # return width and height as string would make "75">"100" be True, which 247 | # is just error prone. 248 | props = {'url':str,'width':int,'height':int,'label':str,'source':str,'text':str} 249 | for psize in data.rsp.sizes.size: 250 | d = {} 251 | for prop,convert_to_type in props.items(): 252 | d[prop] = convert_to_type(getattr(psize, prop)) 253 | ret.append(d) 254 | return ret 255 | 256 | #def getExif(self): 257 | #method = 'flickr.photos.getExif' 258 | #data = _doget(method, photo_id=self.id) 259 | #ret = [] 260 | #for exif in data.rsp.photo.exif: 261 | #print exif.label, dir(exif) 262 | ##ret.append({exif.label:exif.}) 263 | #return ret 264 | ##raise FlickrError, "No URL found" 265 | 266 | def getLocation(self): 267 | """ 268 | Return the latitude+longitutde of the picture. 269 | Returns None if no location given for this pic. 270 | """ 271 | method = 'flickr.photos.geo.getLocation' 272 | try: 273 | data = _doget(method, photo_id=self.id) 274 | except FlickrError: # Some other error might have occured too!? 275 | return None 276 | loc = data.rsp.photo.location 277 | return [loc.latitude, loc.longitude] 278 | 279 | 280 | class Photoset(object): 281 | """A Flickr photoset.""" 282 | 283 | def __init__(self, id, title, primary, photos=0, description='', \ 284 | secret='', server=''): 285 | self.__id = id 286 | self.__title = title 287 | self.__primary = primary 288 | self.__description = description 289 | self.__count = photos 290 | self.__secret = secret 291 | self.__server = server 292 | 293 | id = property(lambda self: self.__id) 294 | title = property(lambda self: self.__title) 295 | description = property(lambda self: self.__description) 296 | primary = property(lambda self: self.__primary) 297 | 298 | def __len__(self): 299 | return self.__count 300 | 301 | def __str__(self): 302 | return '' % self.id 303 | 304 | def getPhotos(self): 305 | """Returns list of Photos.""" 306 | method = 'flickr.photosets.getPhotos' 307 | data = _doget(method, photoset_id=self.id) 308 | photos = data.rsp.photoset.photo 309 | p = [] 310 | for photo in photos: 311 | p.append(Photo(photo.id, title=photo.title, secret=photo.secret, \ 312 | server=photo.server)) 313 | return p 314 | 315 | def editPhotos(self, photos, primary=None): 316 | """Edit the photos in this set. 317 | 318 | photos - photos for set 319 | primary - primary photo (if None will used current) 320 | """ 321 | method = 'flickr.photosets.editPhotos' 322 | 323 | if primary is None: 324 | primary = self.primary 325 | 326 | ids = [photo.id for photo in photos] 327 | if primary.id not in ids: 328 | ids.append(primary.id) 329 | 330 | _dopost(method, auth=True, photoset_id=self.id,\ 331 | primary_photo_id=primary.id, 332 | photo_ids=ids) 333 | self.__count = len(ids) 334 | return True 335 | 336 | def addPhoto(self, photo): 337 | """Add a photo to this set. 338 | 339 | photo - the photo 340 | """ 341 | method = 'flickr.photosets.addPhoto' 342 | 343 | _dopost(method, auth=True, photoset_id=self.id, photo_id=photo.id) 344 | 345 | self.__count += 1 346 | return True 347 | 348 | def removePhoto(self, photo): 349 | """Remove the photo from this set. 350 | 351 | photo - the photo 352 | """ 353 | method = 'flickr.photosets.removePhoto' 354 | 355 | _dopost(method, auth=True, photoset_id=self.id, photo_id=photo.id) 356 | self.__count = self.__count - 1 357 | return True 358 | 359 | def editMeta(self, title=None, description=None): 360 | """Set metadata for photo. (flickr.photos.setMeta)""" 361 | method = 'flickr.photosets.editMeta' 362 | 363 | if title is None: 364 | title = self.title 365 | if description is None: 366 | description = self.description 367 | 368 | _dopost(method, auth=True, title=title, \ 369 | description=description, photoset_id=self.id) 370 | 371 | self.__title = title 372 | self.__description = description 373 | return True 374 | 375 | #XXX: Delete isn't handled well as the python object will still exist 376 | def delete(self): 377 | """Deletes the photoset. 378 | """ 379 | method = 'flickr.photosets.delete' 380 | 381 | _dopost(method, auth=True, photoset_id=self.id) 382 | return True 383 | 384 | def create(cls, photo, title, description=''): 385 | """Create a new photoset. 386 | 387 | photo - primary photo 388 | """ 389 | if not isinstance(photo, Photo): 390 | raise TypeError, "Photo expected" 391 | 392 | method = 'flickr.photosets.create' 393 | data = _dopost(method, auth=True, title=title,\ 394 | description=description,\ 395 | primary_photo_id=photo.id) 396 | 397 | set = Photoset(data.rsp.photoset.id, title, Photo(photo.id), 398 | photos=1, description=description) 399 | return set 400 | create = classmethod(create) 401 | 402 | 403 | class User(object): 404 | """A Flickr user.""" 405 | 406 | def __init__(self, id, username=None, isadmin=None, ispro=None, \ 407 | realname=None, location=None, firstdate=None, count=None): 408 | """id required, rest optional.""" 409 | self.__loaded = False #so we don't keep loading data 410 | self.__id = id 411 | self.__username = username 412 | self.__isadmin = isadmin 413 | self.__ispro = ispro 414 | self.__realname = realname 415 | self.__location = location 416 | self.__photos_firstdate = firstdate 417 | self.__photos_count = count 418 | 419 | #property fu 420 | id = property(lambda self: self._general_getattr('id')) 421 | username = property(lambda self: self._general_getattr('username')) 422 | isadmin = property(lambda self: self._general_getattr('isadmin')) 423 | ispro = property(lambda self: self._general_getattr('ispro')) 424 | realname = property(lambda self: self._general_getattr('realname')) 425 | location = property(lambda self: self._general_getattr('location')) 426 | photos_firstdate = property(lambda self: \ 427 | self._general_getattr('photos_firstdate')) 428 | photos_firstdatetaken = property(lambda self: \ 429 | self._general_getattr\ 430 | ('photos_firstdatetaken')) 431 | photos_count = property(lambda self: \ 432 | self._general_getattr('photos_count')) 433 | icon_server= property(lambda self: self._general_getattr('icon_server')) 434 | icon_url= property(lambda self: self._general_getattr('icon_url')) 435 | 436 | def _general_getattr(self, var): 437 | """Generic get attribute function.""" 438 | if getattr(self, "_%s__%s" % (self.__class__.__name__, var)) is None \ 439 | and not self.__loaded: 440 | self._load_properties() 441 | return getattr(self, "_%s__%s" % (self.__class__.__name__, var)) 442 | 443 | def _load_properties(self): 444 | """Load User properties from Flickr.""" 445 | method = 'flickr.people.getInfo' 446 | data = _doget(method, user_id=self.__id) 447 | 448 | self.__loaded = True 449 | 450 | person = data.rsp.person 451 | 452 | self.__isadmin = person.isadmin 453 | self.__ispro = person.ispro 454 | self.__icon_server = person.iconserver 455 | if int(person.iconserver) > 0: 456 | self.__icon_url = 'http://photos%s.flickr.com/buddyicons/%s.jpg' \ 457 | % (person.iconserver, self.__id) 458 | else: 459 | self.__icon_url = 'http://www.flickr.com/images/buddyicon.jpg' 460 | 461 | self.__username = person.username.text 462 | self.__realname = person.realname.text 463 | self.__location = person.location.text 464 | self.__photos_firstdate = person.photos.firstdate.text 465 | self.__photos_firstdatetaken = person.photos.firstdatetaken.text 466 | self.__photos_count = person.photos.count.text 467 | 468 | def __str__(self): 469 | return '' % self.id 470 | 471 | def getPhotosets(self): 472 | """Returns a list of Photosets.""" 473 | method = 'flickr.photosets.getList' 474 | data = _doget(method, user_id=self.id) 475 | sets = [] 476 | if isinstance(data.rsp.photosets.photoset, list): 477 | for photoset in data.rsp.photosets.photoset: 478 | sets.append(Photoset(photoset.id, photoset.title.text,\ 479 | Photo(photoset.primary),\ 480 | secret=photoset.secret, \ 481 | server=photoset.server, \ 482 | description=photoset.description.text, 483 | photos=photoset.photos)) 484 | else: 485 | photoset = data.rsp.photosets.photoset 486 | sets.append(Photoset(photoset.id, photoset.title.text,\ 487 | Photo(photoset.primary),\ 488 | secret=photoset.secret, \ 489 | server=photoset.server, \ 490 | description=photoset.description.text, 491 | photos=photoset.photos)) 492 | return sets 493 | 494 | def getPublicFavorites(self, per_page='', page=''): 495 | return favorites_getPublicList(user_id=self.id, per_page=per_page, \ 496 | page=page) 497 | 498 | def getFavorites(self, per_page='', page=''): 499 | return favorites_getList(user_id=self.id, per_page=per_page, \ 500 | page=page) 501 | 502 | class Group(object): 503 | """Flickr Group Pool""" 504 | def __init__(self, id, name=None, members=None, online=None,\ 505 | privacy=None, chatid=None, chatcount=None): 506 | self.__loaded = False 507 | self.__id = id 508 | self.__name = name 509 | self.__members = members 510 | self.__online = online 511 | self.__privacy = privacy 512 | self.__chatid = chatid 513 | self.__chatcount = chatcount 514 | self.__url = None 515 | 516 | id = property(lambda self: self._general_getattr('id')) 517 | name = property(lambda self: self._general_getattr('name')) 518 | members = property(lambda self: self._general_getattr('members')) 519 | online = property(lambda self: self._general_getattr('online')) 520 | privacy = property(lambda self: self._general_getattr('privacy')) 521 | chatid = property(lambda self: self._general_getattr('chatid')) 522 | chatcount = property(lambda self: self._general_getattr('chatcount')) 523 | 524 | def _general_getattr(self, var): 525 | """Generic get attribute function.""" 526 | if getattr(self, "_%s__%s" % (self.__class__.__name__, var)) is None \ 527 | and not self.__loaded: 528 | self._load_properties() 529 | return getattr(self, "_%s__%s" % (self.__class__.__name__, var)) 530 | 531 | def _load_properties(self): 532 | """Loads the properties from Flickr.""" 533 | method = 'flickr.groups.getInfo' 534 | data = _doget(method, group_id=self.id) 535 | 536 | self.__loaded = True 537 | 538 | group = data.rsp.group 539 | 540 | self.__name = photo.name.text 541 | self.__members = photo.members.text 542 | self.__online = photo.online.text 543 | self.__privacy = photo.privacy.text 544 | self.__chatid = photo.chatid.text 545 | self.__chatcount = photo.chatcount.text 546 | 547 | def __str__(self): 548 | return '' % self.id 549 | 550 | def getPhotos(self, tags='', per_page='', page=''): 551 | """Get a list of photo objects for this group""" 552 | method = 'flickr.groups.pools.getPhotos' 553 | data = _doget(method, group_id=self.id, tags=tags,\ 554 | per_page=per_page, page=page) 555 | photos = [] 556 | for photo in data.rsp.photos.photo: 557 | photos.append(_parse_photo(photo)) 558 | return photos 559 | 560 | def add(self, photo): 561 | """Adds a Photo to the group""" 562 | method = 'flickr.groups.pools.add' 563 | _dopost(method, auth=True, photo_id=photo.id, group_id=self.id) 564 | return True 565 | 566 | def remove(self, photo): 567 | """Remove a Photo from the group""" 568 | method = 'flickr.groups.pools.remove' 569 | _dopost(method, auth=True, photo_id=photo.id, group_id=self.id) 570 | return True 571 | 572 | class Tag(object): 573 | def __init__(self, id, author, raw, text): 574 | self.id = id 575 | self.author = author 576 | self.raw = raw 577 | self.text = text 578 | 579 | def __str__(self): 580 | return '' % (self.id, self.text) 581 | 582 | 583 | #Flickr API methods 584 | #see api docs http://www.flickr.com/services/api/ 585 | #for details of each param 586 | 587 | #XXX: Could be Photo.search(cls) 588 | def photos_search(user_id='', auth=False, tags='', tag_mode='', text='',\ 589 | min_upload_date='', max_upload_date='',\ 590 | min_taken_date='', max_taken_date='', \ 591 | license='', per_page='', page='', sort=''): 592 | """Returns a list of Photo objects. 593 | 594 | If auth=True then will auth the user. Can see private etc 595 | """ 596 | method = 'flickr.photos.search' 597 | 598 | data = _doget(method, auth=auth, user_id=user_id, tags=tags, text=text,\ 599 | min_upload_date=min_upload_date,\ 600 | max_upload_date=max_upload_date, \ 601 | min_taken_date=min_taken_date, \ 602 | max_taken_date=max_taken_date, \ 603 | license=license, per_page=per_page,\ 604 | page=page, sort=sort) 605 | photos = [] 606 | if isinstance(data.rsp.photos.photo, list): 607 | for photo in data.rsp.photos.photo: 608 | photos.append(_parse_photo(photo)) 609 | else: 610 | photos = [_parse_photo(data.rsp.photos.photo)] 611 | return photos 612 | 613 | #XXX: Could be class method in User 614 | def people_findByEmail(email): 615 | """Returns User object.""" 616 | method = 'flickr.people.findByEmail' 617 | data = _doget(method, find_email=email) 618 | user = User(data.rsp.user.id, username=data.rsp.user.username.text) 619 | return user 620 | 621 | def people_findByUsername(username): 622 | """Returns User object.""" 623 | method = 'flickr.people.findByUsername' 624 | data = _doget(method, username=username) 625 | user = User(data.rsp.user.id, username=data.rsp.user.username.text) 626 | return user 627 | 628 | #XXX: Should probably be in User as a list User.public 629 | def people_getPublicPhotos(user_id, per_page='', page=''): 630 | """Returns list of Photo objects.""" 631 | method = 'flickr.people.getPublicPhotos' 632 | data = _doget(method, user_id=user_id, per_page=per_page, page=page) 633 | photos = [] 634 | if hasattr(data.rsp.photos, "photo"): # Check if there are photos at all (may be been paging too far). 635 | if isinstance(data.rsp.photos.photo, list): 636 | for photo in data.rsp.photos.photo: 637 | photos.append(_parse_photo(photo)) 638 | else: 639 | photos = [_parse_photo(data.rsp.photos.photo)] 640 | return photos 641 | 642 | #XXX: These are also called from User 643 | def favorites_getList(user_id='', per_page='', page=''): 644 | """Returns list of Photo objects.""" 645 | method = 'flickr.favorites.getList' 646 | data = _doget(method, auth=True, user_id=user_id, per_page=per_page,\ 647 | page=page) 648 | photos = [] 649 | if isinstance(data.rsp.photos.photo, list): 650 | for photo in data.rsp.photos.photo: 651 | photos.append(_parse_photo(photo)) 652 | else: 653 | photos = [_parse_photo(data.rsp.photos.photo)] 654 | return photos 655 | 656 | def favorites_getPublicList(user_id, per_page='', page=''): 657 | """Returns list of Photo objects.""" 658 | method = 'flickr.favorites.getPublicList' 659 | data = _doget(method, auth=False, user_id=user_id, per_page=per_page,\ 660 | page=page) 661 | photos = [] 662 | if isinstance(data.rsp.photos.photo, list): 663 | for photo in data.rsp.photos.photo: 664 | photos.append(_parse_photo(photo)) 665 | else: 666 | photos = [_parse_photo(data.rsp.photos.photo)] 667 | return photos 668 | 669 | def favorites_add(photo_id): 670 | """Add a photo to the user's favorites.""" 671 | method = 'flickr.favorites.add' 672 | _dopost(method, auth=True, photo_id=photo_id) 673 | return True 674 | 675 | def favorites_remove(photo_id): 676 | """Remove a photo from the user's favorites.""" 677 | method = 'flickr.favorites.remove' 678 | _dopost(method, auth=True, photo_id=photo_id) 679 | return True 680 | 681 | def groups_getPublicGroups(): 682 | """Get a list of groups the auth'd user is a member of.""" 683 | method = 'flickr.groups.getPublicGroups' 684 | data = _doget(method, auth=True) 685 | groups = [] 686 | if isinstance(data.rsp.groups.group, list): 687 | for group in data.rsp.groups.group: 688 | groups.append(Group(group.id, name=group.name)) 689 | else: 690 | group = data.rsp.groups.group 691 | groups = [Group(group.id, name=group.name)] 692 | return groups 693 | 694 | def groups_pools_getGroups(): 695 | """Get a list of groups the auth'd user can post photos to.""" 696 | method = 'flickr.groups.pools.getGroups' 697 | data = _doget(method, auth=True) 698 | groups = [] 699 | if isinstance(data.rsp.groups.group, list): 700 | for group in data.rsp.groups.group: 701 | groups.append(Group(group.id, name=group.name, \ 702 | privacy=group.privacy)) 703 | else: 704 | group = data.rsp.groups.group 705 | groups = [Group(group.id, name=group.name, privacy=group.privacy)] 706 | return groups 707 | 708 | 709 | def tags_getListUser(user_id=''): 710 | """Returns a list of tags for the given user (in string format)""" 711 | method = 'flickr.tags.getListUser' 712 | auth = user_id == '' 713 | data = _doget(method, auth=auth, user_id=user_id) 714 | if isinstance(data.rsp.tags.tag, list): 715 | return [tag.text for tag in data.rsp.tags.tag] 716 | else: 717 | return [data.rsp.tags.tag.text] 718 | 719 | def tags_getListUserPopular(user_id='', count=''): 720 | """Gets the popular tags for a user in dictionary form tag=>count""" 721 | method = 'flickr.tags.getListUserPopular' 722 | auth = user_id == '' 723 | data = _doget(method, auth=auth, user_id=user_id) 724 | result = {} 725 | if isinstance(data.rsp.tags.tag, list): 726 | for tag in data.rsp.tags.tag: 727 | result[tag.text] = tag.count 728 | else: 729 | result[data.rsp.tags.tag.text] = data.rsp.tags.tag.count 730 | return result 731 | 732 | def tags_getrelated(tag): 733 | """Gets the related tags for given tag.""" 734 | method = 'flickr.tags.getRelated' 735 | data = _doget(method, auth=False, tag=tag) 736 | if isinstance(data.rsp.tags.tag, list): 737 | return [tag.text for tag in data.rsp.tags.tag] 738 | else: 739 | return [data.rsp.tags.tag.text] 740 | 741 | def contacts_getPublicList(user_id): 742 | """Gets the contacts (Users) for the user_id""" 743 | method = 'flickr.contacts.getPublicList' 744 | data = _doget(method, auth=False, user_id=user_id) 745 | if isinstance(data.rsp.contacts.contact, list): 746 | return [User(user.nsid, username=user.username) \ 747 | for user in data.rsp.contacts.contact] 748 | else: 749 | user = data.rsp.contacts.contact 750 | return [User(user.nsid, username=user.username)] 751 | 752 | def interestingness(): 753 | method = 'flickr.interestingness.getList' 754 | data = _doget(method) 755 | photos = [] 756 | if isinstance(data.rsp.photos.photo , list): 757 | for photo in data.rsp.photos.photo: 758 | photos.append(_parse_photo(photo)) 759 | else: 760 | photos = [_parse_photo(data.rsp.photos.photo)] 761 | return photos 762 | 763 | def test_login(): 764 | method = 'flickr.test.login' 765 | data = _doget(method, auth=True) 766 | user = User(data.rsp.user.id, username=data.rsp.user.username.text) 767 | return user 768 | 769 | def test_echo(): 770 | method = 'flickr.test.echo' 771 | data = _doget(method) 772 | return data.rsp.stat 773 | 774 | 775 | #useful methods 776 | 777 | def _doget(method, auth=False, **params): 778 | #uncomment to check you aren't killing the flickr server 779 | #print "***** do get %s" % method 780 | 781 | #convert lists to strings with ',' between items 782 | for (key, value) in params.items(): 783 | if isinstance(value, list): 784 | params[key] = ','.join([item for item in value]) 785 | 786 | url = '%s%s/?api_key=%s&method=%s&%s'% \ 787 | (HOST, API, API_KEY, method, urlencode(params)) 788 | if auth: 789 | url = url + '&email=%s&password=%s' % (email, password) 790 | 791 | #another useful debug print statement 792 | #print url 793 | 794 | xml = minidom.parse(urlopen(url)) 795 | data = unmarshal(xml) 796 | if not data.rsp.stat == 'ok': 797 | msg = "ERROR [%s]: %s" % (data.rsp.err.code, data.rsp.err.msg) 798 | raise FlickrError, msg 799 | return data 800 | 801 | def _dopost(method, auth=False, **params): 802 | #uncomment to check you aren't killing the flickr server 803 | #print "***** do post %s" % method 804 | 805 | #convert lists to strings with ',' between items 806 | for (key, value) in params.items(): 807 | if isinstance(value, list): 808 | params[key] = ','.join([item for item in value]) 809 | 810 | url = '%s%s/' % (HOST, API) 811 | 812 | payload = 'api_key=%s&method=%s&%s'% \ 813 | (API_KEY, method, urlencode(params)) 814 | if auth: 815 | payload = payload + '&email=%s&password=%s' % (email, password) 816 | 817 | #another useful debug print statement 818 | #print url 819 | #print payload 820 | 821 | xml = minidom.parse(urlopen(url, payload)) 822 | data = unmarshal(xml) 823 | if not data.rsp.stat == 'ok': 824 | msg = "ERROR [%s]: %s" % (data.rsp.err.code, data.rsp.err.msg) 825 | raise FlickrError, msg 826 | return data 827 | 828 | def _parse_photo(photo): 829 | """Create a Photo object from photo data.""" 830 | owner = User(photo.owner) 831 | title = photo.title 832 | ispublic = photo.ispublic 833 | isfriend = photo.isfriend 834 | isfamily = photo.isfamily 835 | secret = photo.secret 836 | server = photo.server 837 | p = Photo(photo.id, owner=owner, title=title, ispublic=ispublic,\ 838 | isfriend=isfriend, isfamily=isfamily, secret=secret, \ 839 | server=server) 840 | return p 841 | 842 | #stolen methods 843 | 844 | class Bag: pass 845 | 846 | #unmarshal taken and modified from pyamazon.py 847 | #makes the xml easy to work with 848 | def unmarshal(element): 849 | rc = Bag() 850 | if isinstance(element, minidom.Element): 851 | for key in element.attributes.keys(): 852 | setattr(rc, key, element.attributes[key].value) 853 | 854 | childElements = [e for e in element.childNodes \ 855 | if isinstance(e, minidom.Element)] 856 | if childElements: 857 | for child in childElements: 858 | key = child.tagName 859 | if hasattr(rc, key): 860 | if type(getattr(rc, key)) <> type([]): 861 | setattr(rc, key, [getattr(rc, key)]) 862 | setattr(rc, key, getattr(rc, key) + [unmarshal(child)]) 863 | elif isinstance(child, minidom.Element) and \ 864 | (child.tagName == 'Details'): 865 | # make the first Details element a key 866 | setattr(rc,key,[unmarshal(child)]) 867 | #dbg: because otherwise 'hasattr' only tests 868 | #dbg: on the second occurence: if there's a 869 | #dbg: single return to a query, it's not a 870 | #dbg: list. This module should always 871 | #dbg: return a list of Details objects. 872 | else: 873 | setattr(rc, key, unmarshal(child)) 874 | else: 875 | #jec: we'll have the main part of the element stored in .text 876 | #jec: will break if tag is also present 877 | text = "".join([e.data for e in element.childNodes \ 878 | if isinstance(e, minidom.Text)]) 879 | setattr(rc, 'text', text) 880 | return rc 881 | 882 | #unique items from a list from the cookbook 883 | def uniq(alist): # Fastest without order preserving 884 | set = {} 885 | map(set.__setitem__, alist, []) 886 | return set.keys() 887 | 888 | if __name__ == '__main__': 889 | print test_echo() 890 | --------------------------------------------------------------------------------