├── example ├── blog │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_comment.py │ │ └── 0001_initial.py │ ├── tests.py │ ├── apps.py │ ├── urls.py │ ├── admin.py │ ├── templates │ │ └── blog │ │ │ ├── post_view.html │ │ │ └── post_update.html │ ├── forms.py │ ├── views.py │ └── models.py ├── example │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── .gitignore └── manage.py ├── django_editorjs_fields ├── templatetags │ ├── __init__.py │ └── editorjs.py ├── __init__.py ├── templates │ └── django-editorjs-fields │ │ └── widget.html ├── utils.py ├── urls.py ├── widgets.py ├── config.py ├── static │ └── django-editorjs-fields │ │ ├── css │ │ └── django-editorjs-fields.css │ │ └── js │ │ └── django-editorjs-fields.js ├── fields.py └── views.py ├── .pylintrc ├── .gitignore ├── LICENSE ├── pyproject.toml ├── README.md └── poetry.lock /example/blog/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | media/ 2 | *.log -------------------------------------------------------------------------------- /example/blog/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_editorjs_fields/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/blog/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable= 3 | C0114, # missing-module-docstring 4 | C0115, 5 | C0116, 6 | -------------------------------------------------------------------------------- /example/blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BlogConfig(AppConfig): 5 | name = 'blog' 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | .idea 4 | 5 | venv/ 6 | .venv/ 7 | dist/ 8 | __pycache__ 9 | 10 | #egg's specific 11 | *.egg-info 12 | 13 | db.sqlite3 14 | -------------------------------------------------------------------------------- /django_editorjs_fields/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.7" 2 | 3 | from .fields import EditorJsJSONField, EditorJsTextField 4 | from .widgets import EditorJsWidget 5 | 6 | __all__ = ("EditorJsTextField", "EditorJsJSONField", "EditorJsWidget", "__version__") 7 | -------------------------------------------------------------------------------- /example/blog/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import PostUpdate, PostView 4 | 5 | urlpatterns = [ 6 | path('posts//edit', PostUpdate.as_view(), name='post_edit'), 7 | path('posts/', PostView.as_view(), name='post_detail'), 8 | ] 9 | -------------------------------------------------------------------------------- /django_editorjs_fields/templates/django-editorjs-fields/widget.html: -------------------------------------------------------------------------------- 1 | 2 |
-------------------------------------------------------------------------------- /example/blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from blog.models import Post, Comment 3 | 4 | 5 | class CommentInline(admin.TabularInline): 6 | model = Comment 7 | extra = 0 8 | 9 | fields = ('content',) 10 | 11 | 12 | @admin.register(Post) 13 | class PostAdmin(admin.ModelAdmin): 14 | inlines = [ 15 | CommentInline, 16 | ] 17 | -------------------------------------------------------------------------------- /example/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/blog/templates/blog/post_view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | {% load editorjs %} 11 | {{ post.body_editorjs | editorjs}} 12 | {{ post.body_custom | editorjs}} 13 | {{ post.body_textfield | editorjs}} 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /django_editorjs_fields/utils.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | from django.conf import settings 4 | from django.utils.module_loading import import_string 5 | 6 | 7 | def get_storage_class(): 8 | return import_string( 9 | getattr( 10 | settings, 11 | 'EDITORJS_STORAGE_BACKEND', 12 | 'django.core.files.storage.DefaultStorage', 13 | ) 14 | )() 15 | 16 | 17 | def get_hostname_from_url(url): 18 | obj_url = urllib.parse.urlsplit(url) 19 | return obj_url.hostname 20 | 21 | 22 | storage = get_storage_class() 23 | -------------------------------------------------------------------------------- /django_editorjs_fields/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.views.decorators import staff_member_required 2 | from django.urls import path 3 | 4 | from .views import ImageByUrl, ImageUploadView, LinkToolView 5 | 6 | urlpatterns = [ 7 | path( 8 | 'image_upload/', 9 | staff_member_required(ImageUploadView.as_view()), 10 | name='editorjs_image_upload', 11 | ), 12 | path( 13 | 'linktool/', 14 | staff_member_required(LinkToolView.as_view()), 15 | name='editorjs_linktool', 16 | ), 17 | path( 18 | 'image_by_url/', 19 | ImageByUrl.as_view(), 20 | name='editorjs_image_by_url', 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /example/blog/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django_editorjs_fields import EditorJsWidget 3 | 4 | from .models import Post 5 | 6 | 7 | class TestForm(forms.ModelForm): 8 | # body_editorjs = EditorJsWidget(config={"minHeight": 100, 'autofocus': False}) 9 | 10 | # inputs = forms.JSONField(widget=EditorJsWidget()) 11 | # inputs.widget.config = {"minHeight": 100} 12 | 13 | class Meta: 14 | model = Post 15 | exclude = [] 16 | widgets = { 17 | 'body_editorjs': EditorJsWidget(config={'minHeight': 100}), 18 | 'body_textfield': EditorJsWidget(plugins=[ 19 | "@editorjs/image", 20 | "@editorjs/header" 21 | ], config={'minHeight': 100}) 22 | } 23 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /example/blog/migrations/0002_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-22 14:33 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django_editorjs_fields.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('blog', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Comment', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('content', django_editorjs_fields.fields.EditorJsJSONField(blank=True, null=True)), 20 | ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.post')), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /example/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-15 14:14 2 | 3 | from django.db import migrations, models 4 | import django_editorjs_fields.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Post', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('body_default', models.TextField()), 20 | ('body_editorjs', django_editorjs_fields.fields.EditorJsJSONField()), 21 | ('body_custom', django_editorjs_fields.fields.EditorJsJSONField(blank=True, null=True)), 22 | ('body_textfield', django_editorjs_fields.fields.EditorJsTextField(blank=True, null=True)), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /example/blog/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect, render 2 | from django.views import View 3 | 4 | from .forms import TestForm 5 | from .models import Post 6 | 7 | 8 | class PostUpdate(View): 9 | def get(self, request, pk): 10 | post = Post.objects.get(id=pk) 11 | bound_form = TestForm(instance=post) 12 | return render(request, 'blog/post_update.html', {'form': bound_form, 'post': post}) 13 | 14 | def post(self, request, pk): 15 | post = Post.objects.get(id=pk) 16 | bound_form = TestForm(request.POST, instance=post) 17 | 18 | if bound_form.is_valid(): 19 | new_post = bound_form.save() 20 | return redirect(new_post) 21 | return render(request, 'blog/post_update.html', {'form': bound_form, 'post': post}) 22 | 23 | 24 | class PostView(View): 25 | def get(self, request, pk): 26 | post = Post.objects.get(id=pk) 27 | return render(request, 'blog/post_view.html', {'post': post}) 28 | -------------------------------------------------------------------------------- /example/blog/templates/blog/post_update.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | {% if form %} 11 | 12 | {% if form.errors %} 13 | 18 | {% endif %} 19 | 20 | 21 |
22 | {% csrf_token %} 23 | {% for field in form %} 24 |
25 | 26 | {{ field }} 27 |
28 | {% endfor %} 29 | 30 |
31 | {% endif %} 32 | 33 | {{ form.media }} 34 | 35 | 36 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | from django.conf import settings 19 | from django.conf.urls.static import static 20 | 21 | urlpatterns = [ 22 | path('admin/', admin.site.urls), 23 | path('editorjs/', include('django_editorjs_fields.urls')), 24 | path('', include('blog.urls')), 25 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ilya Kotlyakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-editorjs-fields" 3 | version = "0.2.7" 4 | description = "Django plugin for using Editor.js" 5 | authors = ["Ilya Kotlyakov "] 6 | license = "MIT" 7 | repository = "https://github.com/2ik/django-editorjs-fields" 8 | documentation = "https://github.com/2ik/django-editorjs-fields" 9 | readme = "README.md" 10 | keywords = ["editorjs", "django-editor", "django-wysiwyg", "wysiwyg", "django-admin"] 11 | 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Operating System :: OS Independent", 15 | "Intended Audience :: Developers", 16 | "Framework :: Django", 17 | "Framework :: Django :: 2.2", 18 | "Framework :: Django :: 3.0", 19 | "Framework :: Django :: 3.1", 20 | "Framework :: Django :: 3.2", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.6", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Topic :: Software Development :: Libraries :: Application Frameworks", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | ] 31 | 32 | include = [ 33 | "LICENSE", 34 | ] 35 | 36 | [tool.poetry.dependencies] 37 | python = "^3.6" 38 | 39 | [tool.poetry.dev-dependencies] 40 | Django = "^3.1.0" 41 | pylint = "^2.6.0" 42 | autopep8 = "^1.5.4" 43 | pylint-django = "^2.3.0" 44 | 45 | [build-system] 46 | requires = ["poetry>=0.12"] 47 | build-backend = "poetry.masonry.api" 48 | -------------------------------------------------------------------------------- /example/blog/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | from django_editorjs_fields import EditorJsJSONField, EditorJsTextField 4 | 5 | 6 | class Post(models.Model): 7 | body_default = models.TextField() 8 | body_editorjs = EditorJsJSONField(readOnly=False, autofocus=True) 9 | body_custom = EditorJsJSONField( 10 | plugins=[ 11 | "@editorjs/image", 12 | "@editorjs/header", 13 | "@editorjs/list", 14 | "editorjs-github-gist-plugin", 15 | "editorjs-hyperlink", 16 | "@editorjs/code", 17 | "@editorjs/inline-code", 18 | "@editorjs/table@1.3.0", 19 | ], 20 | tools={ 21 | "Gist": { 22 | "class": "Gist" 23 | }, 24 | "Hyperlink": { 25 | "class": "Hyperlink", 26 | "config": { 27 | "shortcut": 'CMD+L', 28 | "target": '_blank', 29 | "rel": 'nofollow', 30 | "availableTargets": ['_blank', '_self'], 31 | "availableRels": ['author', 'noreferrer'], 32 | "validate": False, 33 | } 34 | }, 35 | "Image": { 36 | 'class': 'ImageTool', 37 | "config": { 38 | "endpoints": { 39 | # Your custom backend file uploader endpoint 40 | "byFile": "/editorjs/image_upload/" 41 | } 42 | } 43 | } 44 | }, 45 | null=True, 46 | blank=True, 47 | ) 48 | body_textfield = EditorJsTextField( # only images and paragraph (default) 49 | plugins=["@editorjs/image", "@editorjs/embed"], null=True, blank=True, 50 | i18n={ 51 | 'messages': { 52 | 'blockTunes': { 53 | "delete": { 54 | "Delete": "Удалить" 55 | }, 56 | "moveUp": { 57 | "Move up": "Переместить вверх" 58 | }, 59 | "moveDown": { 60 | "Move down": "Переместить вниз" 61 | } 62 | } 63 | }, 64 | } 65 | ) 66 | 67 | def get_absolute_url(self): 68 | return reverse('post_detail', kwargs={'pk': self.id}) 69 | 70 | def __str__(self): 71 | return '{}'.format(self.id) 72 | 73 | 74 | class Comment(models.Model): 75 | content = EditorJsJSONField(null=True, blank=True) 76 | post = models.ForeignKey( 77 | 'Post', related_name='comments', on_delete=models.CASCADE) 78 | -------------------------------------------------------------------------------- /django_editorjs_fields/widgets.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.serializers.json import DjangoJSONEncoder 4 | from django.forms import Media, widgets 5 | from django.forms.renderers import get_default_renderer 6 | from django.utils.encoding import force_str 7 | from django.utils.functional import Promise, cached_property 8 | from django.utils.html import conditional_escape 9 | from django.utils.safestring import mark_safe 10 | 11 | from .config import CONFIG_TOOLS, PLUGINS, PLUGINS_KEYS, VERSION 12 | 13 | 14 | class LazyEncoder(DjangoJSONEncoder): 15 | def default(self, obj): 16 | if isinstance(obj, Promise): 17 | return force_str(obj) 18 | return super().default(obj) 19 | 20 | 21 | json_encode = LazyEncoder().encode 22 | 23 | 24 | class EditorJsWidget(widgets.Textarea): 25 | def __init__(self, plugins=None, tools=None, config=None, **kwargs): 26 | self.plugins = plugins 27 | self.tools = tools 28 | self.config = config 29 | 30 | # Fix "__init__() got an unexpected keyword argument 'widget'" 31 | widget = kwargs.pop('widget', None) 32 | if widget: 33 | self.plugins = widget.plugins 34 | self.tools = widget.tools 35 | self.config = widget.config 36 | 37 | super().__init__(**kwargs) 38 | 39 | def configuration(self): 40 | tools = {} 41 | config = self.config or {} 42 | 43 | if self.plugins or self.tools: 44 | custom_tools = self.tools or {} 45 | # get name packages without version 46 | plugins = ['@'.join(p.split('@')[:2]) 47 | for p in self.plugins or PLUGINS] 48 | 49 | for plugin in plugins: 50 | plugin_key = PLUGINS_KEYS.get(plugin) 51 | 52 | if not plugin_key: 53 | continue 54 | 55 | plugin_tools = custom_tools.get( 56 | plugin_key) or CONFIG_TOOLS.get(plugin_key) or {} 57 | plugin_class = plugin_tools.get('class') 58 | 59 | if plugin_class: 60 | 61 | tools[plugin_key] = custom_tools.get( 62 | plugin_key, CONFIG_TOOLS.get(plugin_key) 63 | ) 64 | 65 | tools[plugin_key]['class'] = plugin_class 66 | 67 | custom_tools.pop(plugin_key, None) 68 | 69 | if custom_tools: 70 | tools.update(custom_tools) 71 | else: # default 72 | tools.update(CONFIG_TOOLS) 73 | 74 | config.update(tools=tools) 75 | return config 76 | 77 | @cached_property 78 | def media(self): 79 | js_list = [ 80 | '//cdn.jsdelivr.net/npm/@editorjs/editorjs@' + VERSION # lib 81 | ] 82 | 83 | plugins = self.plugins or PLUGINS 84 | 85 | if plugins: 86 | js_list += ['//cdn.jsdelivr.net/npm/' + p for p in plugins] 87 | 88 | js_list.append('django-editorjs-fields/js/django-editorjs-fields.js') 89 | 90 | return Media( 91 | js=js_list, 92 | css={ 93 | 'all': [ 94 | 'django-editorjs-fields/css/django-editorjs-fields.css' 95 | ] 96 | }, 97 | ) 98 | 99 | def render(self, name, value, attrs=None, renderer=None): 100 | if value is None: 101 | value = '' 102 | 103 | if renderer is None: 104 | renderer = get_default_renderer() 105 | 106 | return mark_safe(renderer.render("django-editorjs-fields/widget.html", { 107 | 'widget': { 108 | 'name': name, 109 | 'value': conditional_escape(force_str(value)), 110 | 'attrs': self.build_attrs(self.attrs, attrs), 111 | 'config': json_encode(self.configuration()), 112 | } 113 | })) 114 | -------------------------------------------------------------------------------- /django_editorjs_fields/config.py: -------------------------------------------------------------------------------- 1 | from secrets import token_urlsafe 2 | 3 | from django.conf import settings 4 | from django.urls import reverse_lazy 5 | 6 | DEBUG = getattr(settings, "DEBUG", False) 7 | 8 | VERSION = getattr(settings, "EDITORJS_VERSION", '2.25.0') 9 | 10 | # ATTACHMENT_REQUIRE_AUTHENTICATION = str( 11 | # getattr(settings, "EDITORJS_ATTACHMENT_REQUIRE_AUTHENTICATION", True) 12 | # ) 13 | 14 | EMBED_HOSTNAME_ALLOWED = str( 15 | getattr(settings, "EDITORJS_EMBED_HOSTNAME_ALLOWED", ( 16 | 'player.vimeo.com', 17 | 'www.youtube.com', 18 | 'coub.com', 19 | 'vine.co', 20 | 'imgur.com', 21 | 'gfycat.com', 22 | 'player.twitch.tv', 23 | 'player.twitch.tv', 24 | 'music.yandex.ru', 25 | 'codepen.io', 26 | 'www.instagram.com', 27 | 'twitframe.com', 28 | 'assets.pinterest.com', 29 | 'www.facebook.com', 30 | 'www.aparat.com', 31 | )) 32 | ) 33 | 34 | IMAGE_UPLOAD_PATH = str( 35 | getattr(settings, "EDITORJS_IMAGE_UPLOAD_PATH", 'uploads/images/') 36 | ) 37 | 38 | IMAGE_UPLOAD_PATH_DATE = getattr( 39 | settings, "EDITORJS_IMAGE_UPLOAD_PATH_DATE", '%Y/%m/') 40 | 41 | IMAGE_NAME_ORIGINAL = getattr( 42 | settings, "EDITORJS_IMAGE_NAME_ORIGINAL", False) 43 | 44 | IMAGE_NAME = getattr( 45 | settings, "EDITORJS_IMAGE_NAME", lambda **_: token_urlsafe(8)) 46 | 47 | PLUGINS = getattr( 48 | settings, "EDITORJS_DEFAULT_PLUGINS", ( 49 | '@editorjs/paragraph', 50 | '@editorjs/image', 51 | '@editorjs/header', 52 | '@editorjs/list', 53 | '@editorjs/checklist', 54 | '@editorjs/quote', 55 | '@editorjs/raw', 56 | '@editorjs/code', 57 | '@editorjs/inline-code', 58 | '@editorjs/embed', 59 | '@editorjs/delimiter', 60 | '@editorjs/warning', 61 | '@editorjs/link', 62 | '@editorjs/marker', 63 | '@editorjs/table', 64 | ) 65 | ) 66 | 67 | CONFIG_TOOLS = getattr( 68 | settings, "EDITORJS_DEFAULT_CONFIG_TOOLS", { 69 | 'Image': { 70 | 'class': 'ImageTool', 71 | 'inlineToolbar': True, 72 | "config": { 73 | "endpoints": { 74 | "byFile": reverse_lazy('editorjs_image_upload'), 75 | "byUrl": reverse_lazy('editorjs_image_by_url') 76 | } 77 | }, 78 | }, 79 | 'Header': { 80 | 'class': 'Header', 81 | 'inlineToolbar': True, 82 | 'config': { 83 | 'placeholder': 'Enter a header', 84 | 'levels': [2, 3, 4], 85 | 'defaultLevel': 2, 86 | } 87 | }, 88 | 'Checklist': {'class': 'Checklist', 'inlineToolbar': True}, 89 | 'List': {'class': 'List', 'inlineToolbar': True}, 90 | 'Quote': {'class': 'Quote', 'inlineToolbar': True}, 91 | 'Raw': {'class': 'RawTool'}, 92 | 'Code': {'class': 'CodeTool'}, 93 | 'InlineCode': {'class': 'InlineCode'}, 94 | 'Embed': {'class': 'Embed'}, 95 | 'Delimiter': {'class': 'Delimiter'}, 96 | 'Warning': {'class': 'Warning', 'inlineToolbar': True}, 97 | 'LinkTool': { 98 | 'class': 'LinkTool', 99 | 'config': { 100 | # Backend endpoint for url data fetching 101 | 'endpoint': reverse_lazy('editorjs_linktool'), 102 | } 103 | }, 104 | 'Marker': {'class': 'Marker', 'inlineToolbar': True}, 105 | 'Table': {'class': 'Table', 'inlineToolbar': True}, 106 | } 107 | ) 108 | 109 | PLUGINS_KEYS = { 110 | '@editorjs/image': 'Image', 111 | '@editorjs/header': 'Header', 112 | '@editorjs/checklist': 'Checklist', 113 | '@editorjs/list': 'List', 114 | '@editorjs/quote': 'Quote', 115 | '@editorjs/raw': 'Raw', 116 | '@editorjs/code': 'Code', 117 | '@editorjs/inline-code': 'InlineCode', 118 | '@editorjs/embed': 'Embed', 119 | '@editorjs/delimiter': 'Delimiter', 120 | '@editorjs/warning': 'Warning', 121 | '@editorjs/link': 'LinkTool', 122 | '@editorjs/marker': 'Marker', 123 | '@editorjs/table': 'Table', 124 | } 125 | -------------------------------------------------------------------------------- /django_editorjs_fields/static/django-editorjs-fields/css/django-editorjs-fields.css: -------------------------------------------------------------------------------- 1 | div[data-editorjs-holder] { 2 | display: inline-block; 3 | width: 100%; 4 | max-width: 750px; 5 | padding: 1.5em 1em; 6 | border: 1px solid #ccc; 7 | border-radius: 4px; 8 | background-color: #fcfeff; 9 | } 10 | 11 | .codex-editor .ce-rawtool__textarea { 12 | background-color: #010e15; 13 | color: #ccced2; 14 | } 15 | 16 | .codex-editor .cdx-list { 17 | margin: 0; 18 | padding-left: 32px; 19 | outline: none; 20 | } 21 | 22 | .codex-editor .cdx-list__item { 23 | padding: 8px; 24 | line-height: 1.4em; 25 | list-style: inherit; 26 | } 27 | 28 | .codex-editor .cdx-checklist__item-text { 29 | align-self: center; 30 | } 31 | 32 | .codex-editor .ce-header { 33 | padding: 1em 0; 34 | margin: 0; 35 | margin-bottom: -1em; 36 | line-height: 1.4em; 37 | outline: none; 38 | background: transparent; 39 | color: #000; 40 | font-weight: 800; 41 | text-transform: initial; 42 | } 43 | 44 | .codex-editor h2.ce-header { 45 | font-size: 1.5em; 46 | } 47 | 48 | .codex-editor h3.ce-header { 49 | font-size: 1.3em; 50 | } 51 | 52 | .codex-editor h4.ce-header { 53 | font-size: 1.1em; 54 | } 55 | 56 | .codex-editor blockquote { 57 | border: initial; 58 | margin: initial; 59 | color: initial; 60 | font-size: inherit; 61 | } 62 | 63 | .codex-editor .wrapper .cdx-button { 64 | display: none; 65 | } 66 | 67 | .codex-editor .link-tool__progress { 68 | float: initial; 69 | width: 100%; 70 | line-height: initial; 71 | padding: initial; 72 | } 73 | 74 | @media (max-width: 767px) { 75 | div[data-editorjs-holder] { 76 | width: auto; 77 | } 78 | 79 | .aligned .form-row, 80 | .aligned .form-row>div { 81 | flex-direction: column; 82 | } 83 | } 84 | 85 | @media (prefers-color-scheme: dark) { 86 | 87 | .tc-popover, 88 | .tc-wrap { 89 | --color-border: #4c6b7a !important; 90 | --color-background: #264b5d !important; 91 | --color-background-hover: #162a34 !important; 92 | --color-text-secondary: #fbfbfb !important; 93 | } 94 | 95 | .change-form #container #content-main div[data-editorjs-holder] { 96 | border: 1px solid var(--border-color); 97 | background-color: var(--body-bg); 98 | } 99 | 100 | .change-form #container #content-main .link-tool__input { 101 | color: var(--primary); 102 | } 103 | 104 | .change-form #container #content-main .codex-editor .ce-header, 105 | .change-form #container #content-main .codex-editor blockquote { 106 | color: var(--body-fg); 107 | } 108 | 109 | .change-form #container #content-main .codex-editor .ce-rawtool__textarea { 110 | background-color: #264b5d; 111 | color: #fbfbfb; 112 | } 113 | 114 | .change-form #container #content-main .cdx-marker { 115 | background: #fff03b; 116 | } 117 | 118 | .change-form #container #content-main .ce-inline-toolbar { 119 | color: #000; 120 | } 121 | 122 | .change-form #container #content-main ::-moz-selection, 123 | .change-form #container #content-main ::selection { 124 | color: #fff; 125 | background: #616161; 126 | } 127 | 128 | .change-form #container #content-main .ce-block--selected .ce-block__content { 129 | background: #426b8a; 130 | } 131 | 132 | .change-form #container #content-main .codex-editor svg { 133 | fill: #fff 134 | } 135 | 136 | .change-form #container #content-main .ce-toolbar__plus:hover, 137 | .change-form #container #content-main .ce-toolbar__settings-btn:hover { 138 | background-color: #264b5d; 139 | } 140 | 141 | .change-form #container #content-main .ce-popover__item-icon, 142 | .change-form #container #content-main .ce-conversion-tool__icon { 143 | background: #2fa9a9; 144 | } 145 | 146 | .change-form #container #content-main .ce-popover, 147 | .change-form #container #content-main .ce-settings, 148 | .change-form #container #content-main .ce-inline-toolbar, 149 | .change-form #container #content-main .ce-conversion-toolbar { 150 | background-color: #264b5d; 151 | border-color: #4c6b7a; 152 | color: #fbfbfb 153 | } 154 | 155 | .change-form #container #content-main .ce-popover__item:hover, 156 | .change-form #container #content-main .ce-settings__button:hover, 157 | .change-form #container #content-main .cdx-settings-button:hover, 158 | .change-form #container #content-main .ce-inline-toolbar__dropdown:hover, 159 | .change-form #container #content-main .ce-inline-tool:hover, 160 | .change-form #container #content-main .ce-conversion-tool:hover { 161 | background-color: #162a34; 162 | color: #fff 163 | } 164 | } -------------------------------------------------------------------------------- /django_editorjs_fields/fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core import checks 4 | from django.core.exceptions import ValidationError 5 | from django.db.models import Field 6 | from django.forms import Textarea 7 | 8 | from .config import DEBUG, EMBED_HOSTNAME_ALLOWED 9 | from .utils import get_hostname_from_url 10 | from .widgets import EditorJsWidget 11 | 12 | try: 13 | # pylint: disable=ungrouped-imports 14 | from django.db.models import JSONField # Django >= 3.1 15 | except ImportError: 16 | HAS_JSONFIELD = False 17 | else: 18 | HAS_JSONFIELD = True 19 | 20 | __all__ = ['EditorJsTextField', 'EditorJsJSONField'] 21 | 22 | 23 | class FieldMixin(Field): 24 | def get_internal_type(self): 25 | return 'TextField' 26 | 27 | 28 | class EditorJsFieldMixin: 29 | def __init__(self, plugins, tools, **kwargs): 30 | self.use_editorjs = kwargs.pop('use_editorjs', True) 31 | self.plugins = plugins 32 | self.tools = tools 33 | self.config = {} 34 | 35 | if 'autofocus' in kwargs: 36 | self.config['autofocus'] = kwargs.pop('autofocus') 37 | if 'hideToolbar' in kwargs: 38 | self.config['hideToolbar'] = kwargs.pop('hideToolbar') 39 | if 'inlineToolbar' in kwargs: 40 | self.config['inlineToolbar'] = kwargs.pop('inlineToolbar') 41 | if 'readOnly' in kwargs: 42 | self.config['readOnly'] = kwargs.pop('readOnly') 43 | if 'minHeight' in kwargs: 44 | self.config['minHeight'] = kwargs.pop('minHeight') 45 | if 'logLevel' in kwargs: 46 | self.config['logLevel'] = kwargs.pop('logLevel') 47 | if 'placeholder' in kwargs: 48 | self.config['placeholder'] = kwargs.pop('placeholder') 49 | if 'defaultBlock' in kwargs: 50 | self.config['defaultBlock'] = kwargs.pop('defaultBlock') 51 | if 'sanitizer' in kwargs: 52 | self.config['sanitizer'] = kwargs.pop('sanitizer') 53 | if 'i18n' in kwargs: 54 | self.config['i18n'] = kwargs.pop('i18n') 55 | 56 | super().__init__(**kwargs) 57 | 58 | def validate_embed(self, value): 59 | for item in value.get('blocks', []): 60 | type = item.get('type', '').lower() 61 | if type == 'embed': 62 | embed = item['data']['embed'] 63 | hostname = get_hostname_from_url(embed) 64 | 65 | if hostname not in EMBED_HOSTNAME_ALLOWED: 66 | raise ValidationError( 67 | hostname + ' is not allowed in EDITORJS_EMBED_HOSTNAME_ALLOWED') 68 | 69 | def clean(self, value, model_instance): 70 | if value and value != 'null': 71 | if not isinstance(value, dict): 72 | try: 73 | value = json.loads(value) 74 | except ValueError: 75 | pass 76 | except TypeError: 77 | pass 78 | else: 79 | self.validate_embed(value) 80 | value = json.dumps(value) 81 | else: 82 | self.validate_embed(value) 83 | 84 | return super().clean(value, model_instance) 85 | 86 | def formfield(self, **kwargs): 87 | if self.use_editorjs: 88 | kwargs['widget'] = EditorJsWidget( 89 | self.plugins, self.tools, self.config, **kwargs) 90 | else: 91 | kwargs['widget'] = Textarea(**kwargs) 92 | 93 | # pylint: disable=no-member 94 | return super().formfield(**kwargs) 95 | 96 | 97 | class EditorJsTextField(EditorJsFieldMixin, FieldMixin): 98 | # pylint: disable=useless-super-delegation 99 | def __init__(self, plugins=None, tools=None, **kwargs): 100 | super().__init__(plugins, tools, **kwargs) 101 | 102 | def clean(self, value, model_instance): 103 | if value == 'null': 104 | value = None 105 | 106 | return super().clean(value, model_instance) 107 | 108 | 109 | class EditorJsJSONField(EditorJsFieldMixin, JSONField if HAS_JSONFIELD else FieldMixin): 110 | # pylint: disable=useless-super-delegation 111 | def __init__(self, plugins=None, tools=None, **kwargs): 112 | super().__init__(plugins, tools, **kwargs) 113 | 114 | def check(self, **kwargs): 115 | errors = super().check(**kwargs) 116 | errors.extend(self._check_supported_json()) 117 | return errors 118 | 119 | def _check_supported_json(self): 120 | if not HAS_JSONFIELD and DEBUG: 121 | return [ 122 | checks.Warning( 123 | 'You don\'t support JSONField, please use' 124 | 'EditorJsTextField instead of EditorJsJSONField', 125 | obj=self, 126 | ) 127 | ] 128 | return [] 129 | -------------------------------------------------------------------------------- /django_editorjs_fields/static/django-editorjs-fields/js/django-editorjs-fields.js: -------------------------------------------------------------------------------- 1 | ;(function () { 2 | var pluginName = "django_editorjs_fields" 3 | var pluginHelp = 4 | "Write about the issue here: https://github.com/2ik/django-editorjs-fields/issues" 5 | 6 | function initEditorJsPlugin() { 7 | var fields = document.querySelectorAll("[data-editorjs-textarea]") 8 | 9 | for (let i = 0; i < fields.length; i++) { 10 | initEditorJsField(fields[i]) 11 | } 12 | } 13 | 14 | function initEditorJsField(textarea) { 15 | if (!textarea) { 16 | logError("bad textarea") 17 | return false 18 | } 19 | 20 | var id = textarea.getAttribute("id") 21 | 22 | if (!id) { 23 | logError("empty field 'id'") 24 | holder.remove() 25 | return false 26 | } 27 | 28 | var holder = document.getElementById(id + "_editorjs_holder") 29 | 30 | if (!holder) { 31 | logError("holder not found") 32 | holder.remove() 33 | return false 34 | } 35 | 36 | if (id.indexOf("__prefix__") !== -1) return 37 | 38 | try { 39 | var config = JSON.parse(textarea.getAttribute("data-config")) 40 | } catch (error) { 41 | console.error(error) 42 | logError( 43 | "invalid 'data-config' on field: " + id + " . Clear the field manually" 44 | ) 45 | holder.remove() 46 | return false 47 | } 48 | 49 | var text = textarea.value.trim() 50 | 51 | if (text) { 52 | try { 53 | text = JSON.parse(text) 54 | } catch (error) { 55 | console.error(error) 56 | logError( 57 | "invalid json data from the database. Clear the field manually" 58 | ) 59 | holder.remove() 60 | return false 61 | } 62 | } 63 | 64 | textarea.style.display = "none" // remove old textarea 65 | 66 | var editorConfig = { 67 | id: id, 68 | holder: holder, 69 | data: text, 70 | } 71 | 72 | if ("tools" in config) { 73 | // set config 74 | var tools = config.tools 75 | 76 | for (var plugin in tools) { 77 | var cls = tools[plugin].class 78 | 79 | if (cls && window[cls] != undefined) { 80 | tools[plugin].class = eval(cls) 81 | continue 82 | } 83 | 84 | delete tools[plugin] 85 | logError("[" + plugin + "] Class " + cls + " Not Found") 86 | } 87 | 88 | editorConfig.tools = tools 89 | } 90 | 91 | if ("autofocus" in config) { 92 | editorConfig.autofocus = !!config.autofocus 93 | } 94 | 95 | if ("hideToolbar" in config) { 96 | editorConfig.hideToolbar = !!config.hideToolbar 97 | } 98 | 99 | if ("inlineToolbar" in config) { 100 | editorConfig.inlineToolbar = config.inlineToolbar 101 | } 102 | 103 | if ("readOnly" in config) { 104 | editorConfig.readOnly = config.readOnly 105 | } 106 | 107 | if ("minHeight" in config) { 108 | editorConfig.minHeight = config.minHeight || 300 109 | } 110 | 111 | if ("logLevel" in config) { 112 | editorConfig.logLevel = config.logLevel || "VERBOSE" 113 | } else { 114 | editorConfig.logLevel = "ERROR" 115 | } 116 | 117 | if ("placeholder" in config) { 118 | editorConfig.placeholder = config.placeholder || "Type text..." 119 | } else { 120 | editorConfig.placeholder = "Type text..." 121 | } 122 | 123 | if ("defaultBlock" in config) { 124 | editorConfig.defaultBlock = config.defaultBlock || "paragraph" 125 | } 126 | 127 | if ("sanitizer" in config) { 128 | editorConfig.sanitizer = config.sanitizer || { 129 | p: true, 130 | b: true, 131 | a: true, 132 | } 133 | } 134 | 135 | if ("i18n" in config) { 136 | editorConfig.i18n = config.i18n || {} 137 | } 138 | 139 | editorConfig.onChange = function () { 140 | editor 141 | .save() 142 | .then(function (data) { 143 | if (data.blocks.length) { 144 | textarea.value = JSON.stringify(data) 145 | } else { 146 | textarea.value = 'null' 147 | } 148 | }) 149 | .catch(function (error) { 150 | console.log("save error: ", error) 151 | }) 152 | } 153 | var editor = new EditorJS(editorConfig) 154 | holder.setAttribute("data-processed", 1) 155 | } 156 | 157 | function logError(msg) { 158 | console.error(pluginName + " - " + msg + ". " + pluginHelp) 159 | } 160 | 161 | addEventListener("DOMContentLoaded", initEditorJsPlugin) 162 | 163 | // Event 164 | if (typeof django === "object" && django.jQuery) { 165 | django.jQuery(document).on("formset:added", function (event, $row) { 166 | var areas = $row.find("[data-editorjs-textarea]").get() 167 | 168 | if (areas) { 169 | for (let i = 0; i < areas.length; i++) { 170 | initEditorJsField(areas[i]) 171 | } 172 | } 173 | }) 174 | } 175 | })() 176 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '9yx!8#ocntj+t40#&2+qj&+)n1ajvx_-mo5247&evr*)37=y_x' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 31 | 32 | # Application definition 33 | 34 | DEFAULT_APPS = [ 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | ] 42 | 43 | INSTALLED_APPS = DEFAULT_APPS + [ 44 | 'blog.apps.BlogConfig', 45 | 'django_editorjs_fields', 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | 'django.middleware.security.SecurityMiddleware', 50 | 'django.contrib.sessions.middleware.SessionMiddleware', 51 | 'django.middleware.common.CommonMiddleware', 52 | 'django.middleware.csrf.CsrfViewMiddleware', 53 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 54 | 'django.contrib.messages.middleware.MessageMiddleware', 55 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 56 | ] 57 | 58 | ROOT_URLCONF = 'example.urls' 59 | 60 | TEMPLATES = [ 61 | { 62 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 63 | 'DIRS': [], 64 | 'APP_DIRS': True, 65 | 'OPTIONS': { 66 | 'context_processors': [ 67 | 'django.template.context_processors.debug', 68 | 'django.template.context_processors.request', 69 | 'django.contrib.auth.context_processors.auth', 70 | 'django.contrib.messages.context_processors.messages', 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | WSGI_APPLICATION = 'example.wsgi.application' 77 | 78 | 79 | # Database 80 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 81 | 82 | DATABASES = { 83 | # 'default': { 84 | # 'ENGINE': 'django.db.backends.sqlite3', 85 | # 'NAME': BASE_DIR / 'db.sqlite3', 86 | # } 87 | 'default': { 88 | 'ENGINE': 'django.db.backends.postgresql', 89 | 'NAME': 'test_django', 90 | 'USER': 'postgres', 91 | 'PASSWORD': 'postgres', 92 | 'HOST': '127.0.0.1', 93 | 'PORT': '5432', 94 | } 95 | } 96 | 97 | LOGGING = { 98 | 'version': 1, 99 | 'disable_existing_loggers': False, 100 | 'formatters': { 101 | 'standard': { 102 | 'format': '%(asctime)s %(filename)s:%(lineno)d %(levelname)s - %(message)s' 103 | }, 104 | }, 105 | 'handlers': { 106 | 'console': { 107 | 'class': 'logging.StreamHandler', 108 | }, 109 | 'django_editorjs_fields': { 110 | 'level': 'DEBUG', 111 | 'class': 'logging.handlers.RotatingFileHandler', 112 | 'filename': 'django_editorjs_fields.log', 113 | 'maxBytes': 1024*1024*5, # 5 MB 114 | 'backupCount': 5, 115 | 'formatter': 'standard', 116 | }, 117 | }, 118 | 'loggers': { 119 | 'django_editorjs_fields': { 120 | 'handlers': ['django_editorjs_fields', 'console'], 121 | 'level': 'DEBUG', 122 | }, 123 | }, 124 | } 125 | 126 | 127 | # Password validation 128 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 129 | 130 | AUTH_PASSWORD_VALIDATORS = [ 131 | { 132 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 133 | }, 134 | { 135 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 136 | }, 137 | { 138 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 139 | }, 140 | { 141 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 142 | }, 143 | ] 144 | 145 | 146 | # Internationalization 147 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 148 | 149 | LANGUAGE_CODE = 'en-us' 150 | 151 | TIME_ZONE = 'UTC' 152 | 153 | USE_I18N = True 154 | 155 | USE_L10N = True 156 | 157 | USE_TZ = True 158 | 159 | 160 | # Static files (CSS, JavaScript, Images) 161 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 162 | 163 | STATIC_ROOT = BASE_DIR / "static/" 164 | STATIC_URL = '/static/' 165 | 166 | MEDIA_ROOT = BASE_DIR / "media" 167 | MEDIA_URL = "/media/" 168 | 169 | # django_editorjs_fields 170 | EDITORJS_VERSION = '2.25.0' 171 | # EDITORJS_IMAGE_NAME_ORIGINAL = True 172 | # EDITORJS_IMAGE_UPLOAD_PATH_DATE = None 173 | # EDITORJS_IMAGE_NAME = lambda filename, **_: f"{filename}_12345" 174 | -------------------------------------------------------------------------------- /django_editorjs_fields/templatetags/editorjs.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django import template 4 | from django.utils.safestring import mark_safe 5 | 6 | register = template.Library() 7 | 8 | 9 | def generate_paragraph(data): 10 | text = data.get('text').replace(' ', ' ') 11 | return f'

{text}

' 12 | 13 | 14 | def generate_list(data): 15 | list_li = ''.join([f'
  • {item}
  • ' for item in data.get('items')]) 16 | tag = 'ol' if data.get('style') == 'ordered' else 'ul' 17 | return f'<{tag}>{list_li}' 18 | 19 | 20 | def generate_header(data): 21 | text = data.get('text').replace(' ', ' ') 22 | level = data.get('level') 23 | return f'{text}' 24 | 25 | 26 | def generate_image(data): 27 | url = data.get('file', {}).get('url') 28 | caption = data.get('caption') 29 | classes = [] 30 | 31 | if data.get('stretched'): 32 | classes.append('stretched') 33 | if data.get('withBorder'): 34 | classes.append('withBorder') 35 | if data.get('withBackground'): 36 | classes.append('withBackground') 37 | 38 | classes = ' '.join(classes) 39 | 40 | return f'{caption}' 41 | 42 | 43 | def generate_delimiter(): 44 | return '
    ' 45 | 46 | 47 | def generate_table(data): 48 | rows = data.get('content', []) 49 | table = '' 50 | 51 | for row in rows: 52 | table += '' 53 | for cell in row: 54 | table += f'{cell}' 55 | table += '' 56 | 57 | return f'{table}
    ' 58 | 59 | 60 | def generate_warning(data): 61 | title, message = data.get('title'), data.get('message') 62 | 63 | if title: 64 | title = f'
    {title}
    ' 65 | if message: 66 | message = f'
    {message}
    ' 67 | 68 | return f'
    {title}{message}
    ' 69 | 70 | 71 | def generate_quote(data): 72 | alignment = data.get('alignment') 73 | caption = data.get('caption') 74 | text = data.get('text') 75 | 76 | if caption: 77 | caption = f'{caption}' 78 | 79 | classes = f'align-{alignment}' if alignment else None 80 | 81 | return f'
    {text}{caption}
    ' 82 | 83 | 84 | def generate_code(data): 85 | code = data.get('code') 86 | return f'{code}' 87 | 88 | 89 | def generate_raw(data): 90 | return data.get('html') 91 | 92 | 93 | def generate_embed(data): 94 | service = data.get('service') 95 | caption = data.get('caption') 96 | embed = data.get('embed') 97 | iframe = f'' 98 | 99 | return f'
    {iframe}{caption}
    ' 100 | 101 | 102 | def generate_link(data): 103 | 104 | link, meta = data.get('link'), data.get('meta') 105 | 106 | if not link or not meta: 107 | return '' 108 | 109 | title = meta.get('title') 110 | description = meta.get('description') 111 | image = meta.get('image') 112 | 113 | wrapper = f'' 127 | return wrapper 128 | 129 | 130 | @register.filter(is_safe=True) 131 | def editorjs(value): 132 | if not value or value == 'null': 133 | return "" 134 | 135 | if not isinstance(value, dict): 136 | try: 137 | value = json.loads(value) 138 | except ValueError: 139 | return value 140 | except TypeError: 141 | return value 142 | 143 | html_list = [] 144 | for item in value['blocks']: 145 | 146 | type, data = item.get('type'), item.get('data') 147 | type = type.lower() 148 | 149 | if type == 'paragraph': 150 | html_list.append(generate_paragraph(data)) 151 | elif type == 'header': 152 | html_list.append(generate_header(data)) 153 | elif type == 'list': 154 | html_list.append(generate_list(data)) 155 | elif type == 'image': 156 | html_list.append(generate_image(data)) 157 | elif type == 'delimiter': 158 | html_list.append(generate_delimiter()) 159 | elif type == 'warning': 160 | html_list.append(generate_warning(data)) 161 | elif type == 'table': 162 | html_list.append(generate_table(data)) 163 | elif type == 'code': 164 | html_list.append(generate_code(data)) 165 | elif type == 'raw': 166 | html_list.append(generate_raw(data)) 167 | elif type == 'embed': 168 | html_list.append(generate_embed(data)) 169 | elif type == 'quote': 170 | html_list.append(generate_quote(data)) 171 | elif type == 'linktool': 172 | html_list.append(generate_link(data)) 173 | 174 | return mark_safe(''.join(html_list)) 175 | -------------------------------------------------------------------------------- /django_editorjs_fields/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from datetime import datetime 5 | from urllib.error import HTTPError, URLError 6 | from urllib.parse import urlencode 7 | from urllib.request import Request, urlopen 8 | 9 | # from django.conf import settings 10 | from django.core.exceptions import ValidationError 11 | from django.core.validators import URLValidator 12 | from django.http import JsonResponse 13 | from django.utils.decorators import method_decorator 14 | from django.views import View 15 | from django.views.decorators.csrf import csrf_exempt 16 | 17 | from .config import (IMAGE_NAME, IMAGE_NAME_ORIGINAL, IMAGE_UPLOAD_PATH, 18 | IMAGE_UPLOAD_PATH_DATE) 19 | from .utils import storage 20 | 21 | LOGGER = logging.getLogger('django_editorjs_fields') 22 | 23 | 24 | class ImageUploadView(View): 25 | http_method_names = ["post"] 26 | # http_method_names = ["post", "delete"] 27 | 28 | @method_decorator(csrf_exempt) 29 | def dispatch(self, request, *args, **kwargs): 30 | return super().dispatch(request, *args, **kwargs) 31 | 32 | def post(self, request): 33 | if 'image' in request.FILES: 34 | the_file = request.FILES['image'] 35 | allowed_types = [ 36 | 'image/jpeg', 37 | 'image/jpg', 38 | 'image/pjpeg', 39 | 'image/x-png', 40 | 'image/png', 41 | 'image/webp', 42 | 'image/gif', 43 | ] 44 | if the_file.content_type not in allowed_types: 45 | return JsonResponse( 46 | {'success': 0, 'message': 'You can only upload images.'} 47 | ) 48 | 49 | filename, extension = os.path.splitext(the_file.name) 50 | 51 | if IMAGE_NAME_ORIGINAL is False: 52 | filename = IMAGE_NAME(filename=filename, file=the_file) 53 | 54 | filename += extension 55 | 56 | upload_path = IMAGE_UPLOAD_PATH 57 | 58 | if IMAGE_UPLOAD_PATH_DATE: 59 | upload_path += datetime.now().strftime(IMAGE_UPLOAD_PATH_DATE) 60 | 61 | path = storage.save( 62 | os.path.join(upload_path, filename), the_file 63 | ) 64 | link = storage.url(path) 65 | 66 | return JsonResponse({'success': 1, 'file': {"url": link}}) 67 | return JsonResponse({'success': 0}) 68 | 69 | # def delete(self, request): 70 | # path_file = request.GET.get('pathFile') 71 | 72 | # if not path_file: 73 | # return JsonResponse({'success': 0, 'message': 'Parameter "pathFile" Not Found'}) 74 | 75 | # base_dir = getattr(settings, "BASE_DIR", '') 76 | # path_file = f'{base_dir}{path_file}' 77 | 78 | # if not os.path.isfile(path_file): 79 | # return JsonResponse({'success': 0, 'message': 'File Not Found'}) 80 | 81 | # os.remove(path_file) 82 | 83 | # return JsonResponse({'success': 1}) 84 | 85 | 86 | class LinkToolView(View): 87 | http_method_names = ["get"] 88 | 89 | @method_decorator(csrf_exempt) 90 | def dispatch(self, request, *args, **kwargs): 91 | return super().dispatch(request, *args, **kwargs) 92 | 93 | def get(self, request): 94 | 95 | url = request.GET.get('url', '') 96 | 97 | LOGGER.debug('Starting to get meta for: %s', url) 98 | 99 | if not any([url.startswith(s) for s in ('http://', 'https://')]): 100 | LOGGER.debug('Adding the http protocol to the link: %s', url) 101 | url = 'http://' + url 102 | 103 | validate = URLValidator(schemes=['http', 'https']) 104 | 105 | try: 106 | validate(url) 107 | except ValidationError as e: 108 | LOGGER.error(e) 109 | else: 110 | try: 111 | LOGGER.debug('Let\'s try to get meta from: %s', url) 112 | 113 | full_url = 'https://api.microlink.io/?' + \ 114 | urlencode({'url': url}) 115 | 116 | req = Request(full_url, headers={ 117 | 'User-Agent': request.META.get('HTTP_USER_AGENT', 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)') 118 | }) 119 | res = urlopen(req) 120 | except HTTPError as e: 121 | LOGGER.error('The server couldn\'t fulfill the request.') 122 | LOGGER.error('Error code: %s %s', e.code, e.msg) 123 | except URLError as e: 124 | LOGGER.error('We failed to reach a server. url: %s', url) 125 | LOGGER.error('Reason: %s', e.reason) 126 | else: 127 | res_body = res.read() 128 | res_json = json.loads(res_body.decode("utf-8")) 129 | 130 | if 'success' in res_json.get('status'): 131 | data = res_json.get('data') 132 | 133 | if data: 134 | LOGGER.debug('Response meta: %s', data) 135 | meta = {} 136 | meta['title'] = data.get('title') 137 | meta['description'] = data.get('description') 138 | meta['image'] = data.get('image') 139 | 140 | return JsonResponse({ 141 | 'success': 1, 142 | 'link': data.get('url', url), 143 | 'meta': meta 144 | }) 145 | 146 | return JsonResponse({'success': 0}) 147 | 148 | 149 | class ImageByUrl(View): 150 | http_method_names = ["post"] 151 | 152 | @method_decorator(csrf_exempt) 153 | def dispatch(self, request, *args, **kwargs): 154 | return super().dispatch(request, *args, **kwargs) 155 | 156 | def post(self, request): 157 | body = json.loads(request.body.decode()) 158 | if 'url' in body: 159 | return JsonResponse({'success': 1, 'file': {"url": body['url']}}) 160 | return JsonResponse({'success': 0}) 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Editor.js for Django 2 | 3 | Django plugin for using [Editor.js](https://editorjs.io/) 4 | 5 | > This plugin works fine with JSONField in Django >= 3.1 6 | 7 | [![Django Editor.js](https://i.ibb.co/r6xt4HJ/image.png)](https://github.com/2ik/django-editorjs-fields) 8 | 9 | [![Python versions](https://img.shields.io/pypi/pyversions/django-editorjs-fields)](https://pypi.org/project/django-editorjs-fields/) 10 | [![Python versions](https://img.shields.io/pypi/djversions/django-editorjs-fields)](https://pypi.org/project/django-editorjs-fields/) 11 | [![Downloads](https://static.pepy.tech/personalized-badge/django-editorjs-fields?period=total&units=international_system&left_color=grey&right_color=brightgreen&left_text=Downloads)](https://pepy.tech/project/django-editorjs-fields) 12 | 13 | ## Installation 14 | 15 | ```bash 16 | pip install django-editorjs-fields 17 | ``` 18 | 19 | Add `django_editorjs_fields` to `INSTALLED_APPS` in `settings.py` for your project: 20 | 21 | ```python 22 | # settings.py 23 | INSTALLED_APPS = [ 24 | ... 25 | 'django_editorjs_fields', 26 | ] 27 | ``` 28 | 29 | ## Upgrade 30 | 31 | ```bash 32 | pip install django-editorjs-fields --upgrade 33 | python manage.py collectstatic # upgrade js and css files 34 | ``` 35 | 36 | ## Usage 37 | 38 | Add code in your model 39 | 40 | ```python 41 | # models.py 42 | from django.db import models 43 | from django_editorjs_fields import EditorJsJSONField # Django >= 3.1 44 | from django_editorjs_fields import EditorJsTextField 45 | 46 | 47 | class Post(models.Model): 48 | body_default = models.TextField() 49 | body_editorjs = EditorJsJSONField() # Django >= 3.1 50 | body_editorjs_text = EditorJsTextField() 51 | 52 | ``` 53 | 54 | #### New in version 0.2.1. Django Templates support 55 | 56 | ```html 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | Document 66 | 67 | 68 | {% load editorjs %} 69 | {{ post.body_default }} 70 | {{ post.body_editorjs | editorjs}} 71 | {{ post.body_editorjs_text | editorjs}} 72 | 73 | 74 | ``` 75 | 76 | ## Additionally 77 | 78 | You can add custom Editor.js plugins and configs ([List plugins](https://github.com/editor-js/awesome-editorjs)) 79 | 80 | Example custom field in models.py 81 | 82 | ```python 83 | # models.py 84 | from django.db import models 85 | from django_editorjs_fields import EditorJsJSONField 86 | 87 | 88 | class Post(models.Model): 89 | body_editorjs_custom = EditorJsJSONField( 90 | plugins=[ 91 | "@editorjs/image", 92 | "@editorjs/header", 93 | "editorjs-github-gist-plugin", 94 | "@editorjs/code@2.6.0", # version allowed :) 95 | "@editorjs/list@latest", 96 | "@editorjs/inline-code", 97 | "@editorjs/table", 98 | ], 99 | tools={ 100 | "Gist": { 101 | "class": "Gist" # Include the plugin class. See docs Editor.js plugins 102 | }, 103 | "Image": { 104 | "config": { 105 | "endpoints": { 106 | "byFile": "/editorjs/image_upload/" # Your custom backend file uploader endpoint 107 | } 108 | } 109 | } 110 | }, 111 | i18n={ 112 | 'messages': { 113 | 'blockTunes': { 114 | "delete": { 115 | "Delete": "Удалить" 116 | }, 117 | "moveUp": { 118 | "Move up": "Переместить вверх" 119 | }, 120 | "moveDown": { 121 | "Move down": "Переместить вниз" 122 | } 123 | } 124 | }, 125 | } 126 | null=True, 127 | blank=True 128 | ) 129 | 130 | ``` 131 | 132 | **django-editorjs-fields** support this list of Editor.js plugins by default: 133 | 134 | 135 |
    136 | EDITORJS_DEFAULT_PLUGINS 137 | 138 | ```python 139 | EDITORJS_DEFAULT_PLUGINS = ( 140 | '@editorjs/paragraph', 141 | '@editorjs/image', 142 | '@editorjs/header', 143 | '@editorjs/list', 144 | '@editorjs/checklist', 145 | '@editorjs/quote', 146 | '@editorjs/raw', 147 | '@editorjs/code', 148 | '@editorjs/inline-code', 149 | '@editorjs/embed', 150 | '@editorjs/delimiter', 151 | '@editorjs/warning', 152 | '@editorjs/link', 153 | '@editorjs/marker', 154 | '@editorjs/table', 155 | ) 156 | ``` 157 | 158 |
    159 | 160 |
    161 | EDITORJS_DEFAULT_CONFIG_TOOLS 162 | 163 | ```python 164 | EDITORJS_DEFAULT_CONFIG_TOOLS = { 165 | 'Image': { 166 | 'class': 'ImageTool', 167 | 'inlineToolbar': True, 168 | "config": { 169 | "endpoints": { 170 | "byFile": reverse_lazy('editorjs_image_upload'), 171 | "byUrl": reverse_lazy('editorjs_image_by_url') 172 | } 173 | }, 174 | }, 175 | 'Header': { 176 | 'class': 'Header', 177 | 'inlineToolbar': True, 178 | 'config': { 179 | 'placeholder': 'Enter a header', 180 | 'levels': [2, 3, 4], 181 | 'defaultLevel': 2, 182 | } 183 | }, 184 | 'Checklist': {'class': 'Checklist', 'inlineToolbar': True}, 185 | 'List': {'class': 'List', 'inlineToolbar': True}, 186 | 'Quote': {'class': 'Quote', 'inlineToolbar': True}, 187 | 'Raw': {'class': 'RawTool'}, 188 | 'Code': {'class': 'CodeTool'}, 189 | 'InlineCode': {'class': 'InlineCode'}, 190 | 'Embed': {'class': 'Embed'}, 191 | 'Delimiter': {'class': 'Delimiter'}, 192 | 'Warning': {'class': 'Warning', 'inlineToolbar': True}, 193 | 'LinkTool': { 194 | 'class': 'LinkTool', 195 | 'config': { 196 | 'endpoint': reverse_lazy('editorjs_linktool'), 197 | } 198 | }, 199 | 'Marker': {'class': 'Marker', 'inlineToolbar': True}, 200 | 'Table': {'class': 'Table', 'inlineToolbar': True}, 201 | } 202 | ``` 203 | 204 |
    205 | 206 | `EditorJsJSONField` accepts all the arguments of `JSONField` class. 207 | 208 | `EditorJsTextField` accepts all the arguments of `TextField` class. 209 | 210 | Additionally, it includes arguments such as: 211 | 212 | | Args | Description | Default | 213 | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | 214 | | `plugins` | List plugins Editor.js | `EDITORJS_DEFAULT_PLUGINS` | 215 | | `tools` | Map of Tools to use. Set config `tools` for Editor.js [See docs](https://editorjs.io/configuration#passing-saved-data) | `EDITORJS_DEFAULT_CONFIG_TOOLS` | 216 | | `use_editor_js` | Enables or disables the Editor.js plugin for the field | `True` | 217 | | `autofocus` | If true, set caret at the first Block after Editor is ready | `False` | 218 | | `hideToolbar` | If true, toolbar won't be shown | `False` | 219 | | `inlineToolbar` | Defines default toolbar for all tools. | `True` | 220 | | `readOnly` | Enable read-only mode | `False` | 221 | | `minHeight` | Height of Editor's bottom area that allows to set focus on the last Block | `300` | 222 | | `logLevel` | Editors log level (how many logs you want to see) | `ERROR` | 223 | | `placeholder` | First Block placeholder | `Type text...` | 224 | | `defaultBlock` | This Tool will be used as default. Name should be equal to one of Tool`s keys of passed tools. If not specified, Paragraph Tool will be used | `paragraph` | 225 | | `i18n` | Internalization config | `{}` | 226 | | `sanitizer` | Define default sanitizer configuration | `{ p: true, b: true, a: true }` | 227 | 228 | ## Image uploads 229 | 230 | If you want to upload images to the editor then add `django_editorjs_fields.urls` to `urls.py` for your project with `DEBUG=True`: 231 | 232 | ```python 233 | # urls.py 234 | from django.contrib import admin 235 | from django.urls import path, include 236 | from django.conf import settings 237 | from django.conf.urls.static import static 238 | 239 | urlpatterns = [ 240 | ... 241 | path('editorjs/', include('django_editorjs_fields.urls')), 242 | ... 243 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 244 | ``` 245 | 246 | In production `DEBUG=False` (use nginx to display images): 247 | 248 | ```python 249 | # urls.py 250 | from django.contrib import admin 251 | from django.urls import path, include 252 | 253 | urlpatterns = [ 254 | ... 255 | path('editorjs/', include('django_editorjs_fields.urls')), 256 | ... 257 | ] 258 | ``` 259 | 260 | See an example of how you can work with the plugin [here](https://github.com/2ik/django-editorjs-fields/blob/main/example) 261 | 262 | ## Forms 263 | 264 | ```python 265 | from django import forms 266 | from django_editorjs_fields import EditorJsWidget 267 | 268 | 269 | class TestForm(forms.ModelForm): 270 | class Meta: 271 | model = Post 272 | exclude = [] 273 | widgets = { 274 | 'body_editorjs': EditorJsWidget(config={'minHeight': 100}), 275 | 'body_editorjs_text': EditorJsWidget(plugins=["@editorjs/image", "@editorjs/header"]) 276 | } 277 | ``` 278 | 279 | ## Theme 280 | 281 | ### Default Theme 282 | 283 | ![image](https://user-images.githubusercontent.com/6692517/124242133-7a7dad00-db2d-11eb-812f-84a5c44e88c9.png) 284 | 285 | ### Dark Theme 286 | 287 | plugin use css property [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) to define a dark theme in browser 288 | 289 | ![image](https://user-images.githubusercontent.com/6692517/124240864-3dfd8180-db2c-11eb-85c1-21f0faf41775.png) 290 | 291 | ## Configure 292 | 293 | The application can be configured by editing the project's `settings.py` 294 | file. 295 | 296 | | Key | Description | Default | Type | 297 | | --------------------------------- | ---------------------------------------------------------------------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------------------------------------------------------------------------------------------------ | 298 | | `EDITORJS_DEFAULT_PLUGINS` | List of plugins names Editor.js from npm | [See above](#plugins) | `list[str]`, `tuple[str]` | 299 | | `EDITORJS_DEFAULT_CONFIG_TOOLS` | Map of Tools to use | [See above](#plugins) | `dict[str, dict]` | 300 | | `EDITORJS_IMAGE_UPLOAD_PATH` | Path uploads images | `uploads/images/` | `str` | 301 | | `EDITORJS_IMAGE_UPLOAD_PATH_DATE` | Subdirectories | `%Y/%m/` | `str` | 302 | | `EDITORJS_IMAGE_NAME_ORIGINAL` | To use the original name of the image file? | `False` | `bool` | 303 | | `EDITORJS_IMAGE_NAME` | Image file name. Ignored when `EDITORJS_IMAGE_NAME_ORIGINAL` is `True` | `token_urlsafe(8)` | `callable(filename: str, file: InMemoryUploadedFile)` ([docs](https://docs.djangoproject.com/en/3.0/ref/files/uploads/)) | 304 | | `EDITORJS_EMBED_HOSTNAME_ALLOWED` | List of allowed hostname for embed | `('player.vimeo.com','www.youtube.com','coub.com','vine.co','imgur.com','gfycat.com','player.twitch.tv','player.twitch.tv','music.yandex.ru','codepen.io','www.instagram.com','twitframe.com','assets.pinterest.com','www.facebook.com','www.aparat.com'),` | `list[str]`, `tuple[str]` | 305 | | `EDITORJS_VERSION` | Version Editor.js | `2.25.0` | `str` | 306 | 307 | For `EDITORJS_IMAGE_NAME` was used `from secrets import token_urlsafe` 308 | 309 | ## Support and updates 310 | 311 | Use github issues https://github.com/2ik/django-editorjs-fields/issues 312 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | category = "dev" 3 | description = "ASGI specs, helper code, and adapters" 4 | name = "asgiref" 5 | optional = false 6 | python-versions = ">=3.6" 7 | version = "3.4.1" 8 | 9 | [package.dependencies] 10 | [package.dependencies.typing-extensions] 11 | python = "<3.8" 12 | version = "*" 13 | 14 | [package.extras] 15 | tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] 16 | 17 | [[package]] 18 | category = "dev" 19 | description = "An abstract syntax tree for Python with inference support." 20 | name = "astroid" 21 | optional = false 22 | python-versions = "~=3.6" 23 | version = "2.8.0" 24 | 25 | [package.dependencies] 26 | lazy-object-proxy = ">=1.4.0" 27 | setuptools = ">=20.0" 28 | wrapt = ">=1.11,<1.13" 29 | 30 | [package.dependencies.typed-ast] 31 | python = "<3.8" 32 | version = ">=1.4.0,<1.5" 33 | 34 | [package.dependencies.typing-extensions] 35 | python = "<3.10" 36 | version = ">=3.10" 37 | 38 | [[package]] 39 | category = "dev" 40 | description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" 41 | name = "autopep8" 42 | optional = false 43 | python-versions = "*" 44 | version = "1.5.7" 45 | 46 | [package.dependencies] 47 | pycodestyle = ">=2.7.0" 48 | toml = "*" 49 | 50 | [[package]] 51 | category = "dev" 52 | description = "Cross-platform colored terminal text." 53 | marker = "sys_platform == \"win32\"" 54 | name = "colorama" 55 | optional = false 56 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 57 | version = "0.4.4" 58 | 59 | [[package]] 60 | category = "dev" 61 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 62 | name = "django" 63 | optional = false 64 | python-versions = ">=3.6" 65 | version = "3.2.7" 66 | 67 | [package.dependencies] 68 | asgiref = ">=3.3.2,<4" 69 | pytz = "*" 70 | sqlparse = ">=0.2.2" 71 | 72 | [package.extras] 73 | argon2 = ["argon2-cffi (>=19.1.0)"] 74 | bcrypt = ["bcrypt"] 75 | 76 | [[package]] 77 | category = "dev" 78 | description = "A Python utility / library to sort Python imports." 79 | name = "isort" 80 | optional = false 81 | python-versions = ">=3.6,<4.0" 82 | version = "5.8.0" 83 | 84 | [package.extras] 85 | colors = ["colorama (>=0.4.3,<0.5.0)"] 86 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 87 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 88 | 89 | [[package]] 90 | category = "dev" 91 | description = "A fast and thorough lazy object proxy." 92 | name = "lazy-object-proxy" 93 | optional = false 94 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 95 | version = "1.6.0" 96 | 97 | [[package]] 98 | category = "dev" 99 | description = "McCabe checker, plugin for flake8" 100 | name = "mccabe" 101 | optional = false 102 | python-versions = "*" 103 | version = "0.6.1" 104 | 105 | [[package]] 106 | category = "dev" 107 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 108 | name = "platformdirs" 109 | optional = false 110 | python-versions = ">=3.6" 111 | version = "2.3.0" 112 | 113 | [package.extras] 114 | docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] 115 | test = ["appdirs (1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 116 | 117 | [[package]] 118 | category = "dev" 119 | description = "Python style guide checker" 120 | name = "pycodestyle" 121 | optional = false 122 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 123 | version = "2.7.0" 124 | 125 | [[package]] 126 | category = "dev" 127 | description = "python code static checker" 128 | name = "pylint" 129 | optional = false 130 | python-versions = "~=3.6" 131 | version = "2.11.1" 132 | 133 | [package.dependencies] 134 | astroid = ">=2.8.0,<2.9" 135 | colorama = "*" 136 | isort = ">=4.2.5,<6" 137 | mccabe = ">=0.6,<0.7" 138 | platformdirs = ">=2.2.0" 139 | toml = ">=0.7.1" 140 | 141 | [package.dependencies.typing-extensions] 142 | python = "<3.10" 143 | version = ">=3.10.0" 144 | 145 | [[package]] 146 | category = "dev" 147 | description = "A Pylint plugin to help Pylint understand the Django web framework" 148 | name = "pylint-django" 149 | optional = false 150 | python-versions = "*" 151 | version = "2.4.4" 152 | 153 | [package.dependencies] 154 | pylint = ">=2.0" 155 | pylint-plugin-utils = ">=0.5" 156 | 157 | [package.extras] 158 | for_tests = ["django-tables2", "factory-boy", "coverage", "pytest"] 159 | with_django = ["django"] 160 | 161 | [[package]] 162 | category = "dev" 163 | description = "Utilities and helpers for writing Pylint plugins" 164 | name = "pylint-plugin-utils" 165 | optional = false 166 | python-versions = "*" 167 | version = "0.6" 168 | 169 | [package.dependencies] 170 | pylint = ">=1.7" 171 | 172 | [[package]] 173 | category = "dev" 174 | description = "World timezone definitions, modern and historical" 175 | name = "pytz" 176 | optional = false 177 | python-versions = "*" 178 | version = "2021.1" 179 | 180 | [[package]] 181 | category = "dev" 182 | description = "A non-validating SQL parser." 183 | name = "sqlparse" 184 | optional = false 185 | python-versions = ">=3.5" 186 | version = "0.4.2" 187 | 188 | [[package]] 189 | category = "dev" 190 | description = "Python Library for Tom's Obvious, Minimal Language" 191 | name = "toml" 192 | optional = false 193 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 194 | version = "0.10.2" 195 | 196 | [[package]] 197 | category = "dev" 198 | description = "a fork of Python 2 and 3 ast modules with type comment support" 199 | marker = "implementation_name == \"cpython\" and python_version < \"3.8\"" 200 | name = "typed-ast" 201 | optional = false 202 | python-versions = "*" 203 | version = "1.4.3" 204 | 205 | [[package]] 206 | category = "dev" 207 | description = "Backported and Experimental Type Hints for Python 3.5+" 208 | marker = "python_version < \"3.10\"" 209 | name = "typing-extensions" 210 | optional = false 211 | python-versions = "*" 212 | version = "3.10.0.2" 213 | 214 | [[package]] 215 | category = "dev" 216 | description = "Module for decorators, wrappers and monkey patching." 217 | name = "wrapt" 218 | optional = false 219 | python-versions = "*" 220 | version = "1.12.1" 221 | 222 | [metadata] 223 | content-hash = "d4fc8dff8bd1539c523aa69a4fcc7505e49211d50b1caf5d7d698527842b5bf2" 224 | lock-version = "1.0" 225 | python-versions = "^3.6" 226 | 227 | [metadata.files] 228 | asgiref = [ 229 | {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, 230 | {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, 231 | ] 232 | astroid = [ 233 | {file = "astroid-2.8.0-py3-none-any.whl", hash = "sha256:dcc06f6165f415220013801642bd6c9808a02967070919c4b746c6864c205471"}, 234 | {file = "astroid-2.8.0.tar.gz", hash = "sha256:fe81f80c0b35264acb5653302ffbd935d394f1775c5e4487df745bf9c2442708"}, 235 | ] 236 | autopep8 = [ 237 | {file = "autopep8-1.5.7-py2.py3-none-any.whl", hash = "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9"}, 238 | {file = "autopep8-1.5.7.tar.gz", hash = "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0"}, 239 | ] 240 | colorama = [ 241 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 242 | ] 243 | django = [ 244 | {file = "Django-3.2.7-py3-none-any.whl", hash = "sha256:e93c93565005b37ddebf2396b4dc4b6913c1838baa82efdfb79acedd5816c240"}, 245 | {file = "Django-3.2.7.tar.gz", hash = "sha256:95b318319d6997bac3595517101ad9cc83fe5672ac498ba48d1a410f47afecd2"}, 246 | ] 247 | isort = [ 248 | {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, 249 | {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, 250 | ] 251 | lazy-object-proxy = [ 252 | {file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"}, 253 | {file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"}, 254 | {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"}, 255 | {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"}, 256 | {file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"}, 257 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"}, 258 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"}, 259 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"}, 260 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"}, 261 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"}, 262 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"}, 263 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"}, 264 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"}, 265 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"}, 266 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"}, 267 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"}, 268 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"}, 269 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"}, 270 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"}, 271 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"}, 272 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"}, 273 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"}, 274 | ] 275 | mccabe = [ 276 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 277 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 278 | ] 279 | platformdirs = [ 280 | {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"}, 281 | {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"}, 282 | ] 283 | pycodestyle = [ 284 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, 285 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, 286 | ] 287 | pylint = [ 288 | {file = "pylint-2.11.1-py3-none-any.whl", hash = "sha256:0f358e221c45cbd4dad2a1e4b883e75d28acdcccd29d40c76eb72b307269b126"}, 289 | {file = "pylint-2.11.1.tar.gz", hash = "sha256:2c9843fff1a88ca0ad98a256806c82c5a8f86086e7ccbdb93297d86c3f90c436"}, 290 | ] 291 | pylint-django = [ 292 | {file = "pylint-django-2.4.4.tar.gz", hash = "sha256:f63f717169b0c2e4e19c28f1c32c28290647330184fcb7427805ae9b6994f3fc"}, 293 | {file = "pylint_django-2.4.4-py3-none-any.whl", hash = "sha256:aff49d9602a39c027b4ed7521a041438893205918f405800063b7ff692b7371b"}, 294 | ] 295 | pylint-plugin-utils = [ 296 | {file = "pylint-plugin-utils-0.6.tar.gz", hash = "sha256:57625dcca20140f43731311cd8fd879318bf45a8b0fd17020717a8781714a25a"}, 297 | {file = "pylint_plugin_utils-0.6-py3-none-any.whl", hash = "sha256:2f30510e1c46edf268d3a195b2849bd98a1b9433229bb2ba63b8d776e1fc4d0a"}, 298 | ] 299 | pytz = [ 300 | {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, 301 | {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, 302 | ] 303 | sqlparse = [ 304 | {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, 305 | {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, 306 | ] 307 | toml = [ 308 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 309 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 310 | ] 311 | typed-ast = [ 312 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, 313 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, 314 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, 315 | {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, 316 | {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, 317 | {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, 318 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, 319 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, 320 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, 321 | {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, 322 | {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, 323 | {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, 324 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, 325 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, 326 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, 327 | {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, 328 | {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, 329 | {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, 330 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, 331 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, 332 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, 333 | {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, 334 | {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, 335 | {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, 336 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, 337 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, 338 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, 339 | {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, 340 | {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, 341 | {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, 342 | ] 343 | typing-extensions = [ 344 | {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, 345 | {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, 346 | {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, 347 | ] 348 | wrapt = [ 349 | {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, 350 | ] 351 | --------------------------------------------------------------------------------