diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..52178c3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.ico binary +*.png binary +*.jpg binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1bca28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Django +db.sqlite3 +media/ +staticfiles/ +logs/ + +# Окружение и локальные файлы +.env +*.bak* +.DS_Store +.idea/ +.vscode/ + +# Тесты и линтеры +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/dspace/asgi.py b/dspace/asgi.py index e81d004..590f57e 100644 --- a/dspace/asgi.py +++ b/dspace/asgi.py @@ -1,7 +1,7 @@ -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings') - -application = get_asgi_application() +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings') + +application = get_asgi_application() diff --git a/dspace/settings.py b/dspace/settings.py index 124700c..478ba9f 100644 --- a/dspace/settings.py +++ b/dspace/settings.py @@ -1,5 +1,6 @@ import os from pathlib import Path +from django.core.exceptions import ImproperlyConfigured from dotenv import load_dotenv load_dotenv() @@ -7,11 +8,18 @@ load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent -SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '*****') +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-dev-only-change-me') DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True' -ALLOWED_HOSTS = ['deev.space', 'deev.su', 'www.deev.space', 'www.deev.su', 'localhost', '127.0.0.1'] +if not DEBUG and SECRET_KEY.startswith('django-insecure'): + raise ImproperlyConfigured('Задайте DJANGO_SECRET_KEY для запуска с DJANGO_DEBUG=False') + +ALLOWED_HOSTS = os.environ.get( + 'DJANGO_ALLOWED_HOSTS', + 'deev.space,www.deev.space,deev.su,www.deev.su,xdeev.ru,www.xdeev.ru,localhost,127.0.0.1', +).split(',') +CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if o] INSTALLED_APPS = [ 'django.contrib.admin', @@ -30,6 +38,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'main.middleware.VisitorMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -58,7 +67,7 @@ WSGI_APPLICATION = 'dspace.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'NAME': os.environ.get('DJANGO_DB_PATH', BASE_DIR / 'db.sqlite3'), } } @@ -98,13 +107,18 @@ MEDIA_ROOT = BASE_DIR / 'media' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Yandex SmartCaptcha Settings -SMARTCAPTCHA_CLIENT_KEY = os.environ.get('SMARTCAPTCHA_CLIENT_KEY', '*****') -SMARTCAPTCHA_SERVER_KEY = os.environ.get('SMARTCAPTCHA_SERVER_KEY', '*****') +SMARTCAPTCHA_CLIENT_KEY = os.environ.get('SMARTCAPTCHA_CLIENT_KEY', '') +SMARTCAPTCHA_SERVER_KEY = os.environ.get('SMARTCAPTCHA_SERVER_KEY', '') + # Telegram Bot Settings TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '') TELEGRAM_CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '') +# AlertBot (Telegram) — уведомления владельцу о новых сообщениях с формы обратной связи +ALERTBOT_TOKEN = os.environ.get('ALERTBOT_TOKEN', '') +ALERTBOT_CHAT_ID = os.environ.get('ALERTBOT_CHAT_ID', '') + # VK API Settings VK_ACCESS_TOKEN = os.environ.get('VK_ACCESS_TOKEN', '') VK_GROUP_ID = os.environ.get('VK_GROUP_ID', '') @@ -116,8 +130,8 @@ EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 465)) EMAIL_USE_SSL = True EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') -DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'egor@deev.space') -CONTACT_EMAIL = os.environ.get('CONTACT_EMAIL', 'egor@deev.space') +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', '') +CONTACT_EMAIL = os.environ.get('CONTACT_EMAIL', '') # Site Settings SITE_NAME = 'deev.space' @@ -133,8 +147,7 @@ CSRF_COOKIE_SECURE = not DEBUG if not DEBUG: SECURE_BROWSER_XSS_FILTER = True SECURE_CONTENT_TYPE_NOSNIFF = True - X_FRAME_OPTIONS = 'DENY' - SECURE_SSL_REDIRECT = True + SECURE_SSL_REDIRECT = os.environ.get('DJANGO_SECURE_SSL_REDIRECT', 'True') == 'True' SECURE_HSTS_SECONDS = 31536000 SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True diff --git a/dspace/urls.py b/dspace/urls.py index 2ce50d3..a3bf7a7 100644 --- a/dspace/urls.py +++ b/dspace/urls.py @@ -1,25 +1,25 @@ -from django.contrib import admin -from django.urls import path, include -from django.conf import settings -from django.conf.urls.static import static -from django.contrib.sitemaps.views import sitemap -from main.sitemaps import ArticleSitemap, ProjectSitemap, StaticViewSitemap - -sitemaps = { - 'articles': ArticleSitemap, - 'projects': ProjectSitemap, - 'static': StaticViewSitemap, -} - -urlpatterns = [ - path('admin/', admin.site.urls), - path('', include('main.urls')), - path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='sitemap'), -] - -if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0]) - -handler404 = 'main.views.handler404' +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.contrib.sitemaps.views import sitemap +from main.sitemaps import ArticleSitemap, ProjectSitemap, StaticViewSitemap + +sitemaps = { + 'articles': ArticleSitemap, + 'projects': ProjectSitemap, + 'static': StaticViewSitemap, +} + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('main.urls')), + path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='sitemap'), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATICFILES_DIRS[0]) + +handler404 = 'main.views.handler404' handler500 = 'main.views.handler500' \ No newline at end of file diff --git a/dspace/wsgi.py b/dspace/wsgi.py index ab7b292..5337da8 100644 --- a/dspace/wsgi.py +++ b/dspace/wsgi.py @@ -1,7 +1,7 @@ -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings') - -application = get_wsgi_application() +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings') + +application = get_wsgi_application() diff --git a/main/admin.py b/main/admin.py index a231c10..11a63a7 100644 --- a/main/admin.py +++ b/main/admin.py @@ -1,262 +1,322 @@ -from django.contrib import admin -from django.utils.html import format_html -from .models import ( - CustomUser, Category, Article, ArticleImage, ArticleFile, ArticleLink, - Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike, - ContactMessage, Experience, Education, SiteSettings -) - - -@admin.register(CustomUser) -class CustomUserAdmin(admin.ModelAdmin): - list_display = ['username', 'email', 'is_verified', 'is_active', 'date_joined'] - list_filter = ['is_verified', 'is_active', 'is_staff', 'date_joined'] - search_fields = ['username', 'email'] - ordering = ['-date_joined'] - readonly_fields = ['date_joined', 'last_login'] - - -@admin.register(Category) -class CategoryAdmin(admin.ModelAdmin): - list_display = ['name', 'slug', 'icon', 'order', 'articles_count'] - list_editable = ['order'] - prepopulated_fields = {'slug': ('name',)} - search_fields = ['name'] - - def articles_count(self, obj): - return obj.articles_count - articles_count.short_description = 'Статей' - - -class ArticleImageInline(admin.TabularInline): - model = ArticleImage - extra = 1 - fields = ['image', 'caption', 'order', 'is_inline'] - ordering = ['order'] - - -class ArticleFileInline(admin.TabularInline): - model = ArticleFile - extra = 1 - fields = ['file', 'title', 'description', 'order', 'is_inline'] - ordering = ['order'] - - -class ArticleLinkInline(admin.TabularInline): - model = ArticleLink - extra = 1 - fields = ['url', 'title', 'description', 'preview_image', 'order', 'is_inline'] - ordering = ['order'] - readonly_fields = [] - - -@admin.register(Article) -class ArticleAdmin(admin.ModelAdmin): - list_display = ['title', 'category', 'is_published', 'comments_enabled', 'is_achievement', 'views', 'date', 'image_preview'] - list_filter = ['is_published', 'comments_enabled', 'is_achievement', 'category', 'date'] - search_fields = ['title', 'post', 'excerpt'] - prepopulated_fields = {'slug': ('title',)} - date_hierarchy = 'date' - list_editable = ['is_published', 'comments_enabled'] - readonly_fields = ['views', 'date', 'updated_at', 'image_preview_large'] - inlines = [ArticleImageInline, ArticleFileInline, ArticleLinkInline] - - fieldsets = ( - ('Основное', { - 'fields': ('title', 'slug', 'sub_title', 'excerpt', 'post') - }), - ('Превью (обложка)', { - 'fields': ('img', 'show_cover_in_article', 'image_preview_large') - }), - ('Галерея изображений', { - 'fields': ('gallery_display_mode',), - 'description': 'Добавьте изображения ниже в инлайн-форме "Изображения статьи"' - }), - ('Категоризация', { - 'fields': ('category', 'author') - }), - ('Комментарии и медиа', { - 'fields': ('comments_enabled', 'exclude_inline_from_blocks'), - 'description': 'Управление комментариями и отображением медиа-контента' - }), - ('Достижение', { - 'fields': ('is_achievement', 'achievement_icon', 'achievement_date'), - 'classes': ('collapse',) - }), - ('Публикация', { - 'fields': ('is_published', 'views', 'date', 'updated_at') - }), - ) - - def image_preview(self, obj): - if obj.img: - return format_html('', obj.img.url) - return '-' - image_preview.short_description = 'Превью' - - def image_preview_large(self, obj): - if obj.img: - return format_html('', obj.img.url) - return 'Нет изображения' - image_preview_large.short_description = 'Изображение' - - -@admin.register(ProjectStatus) -class ProjectStatusAdmin(admin.ModelAdmin): - list_display = ['name', 'slug', 'color', 'is_release', 'order'] - list_editable = ['order', 'is_release'] - prepopulated_fields = {'slug': ('name',)} - search_fields = ['name'] - ordering = ['order'] - - -@admin.register(Project) -class ProjectAdmin(admin.ModelAdmin): - list_display = ['title', 'status', 'card_size', 'languages_short', 'technologies_short', 'show_on_homepage', 'homepage_order', 'order', 'is_visible'] - list_filter = ['status', 'card_size', 'is_visible', 'show_on_homepage'] - search_fields = ['title', 'description', 'technologies', 'programming_languages'] - prepopulated_fields = {'slug': ('title',)} - list_editable = ['order', 'is_visible', 'card_size', 'show_on_homepage', 'homepage_order'] - - fieldsets = ( - ('Основное', { - 'fields': ('title', 'slug', 'short_description', 'description', 'features') - }), - ('Визуальное', { - 'fields': ('img_main', 'icon', 'card_size') - }), - ('Техническое', { - 'fields': ('programming_languages', 'technologies', 'github_url', 'demo_url'), - 'description': 'Языки программирования и технологии указываются через запятую' - }), - ('Статус и отображение', { - 'fields': ('status', 'users_count', 'is_visible', 'order') - }), - ('Главная страница', { - 'fields': ('show_on_homepage', 'homepage_order'), - 'description': 'Настройки отображения проекта на главной странице' - }), - ) - - def languages_short(self, obj): - langs = obj.get_languages_list()[:2] - return ', '.join(langs) + ('...' if len(obj.get_languages_list()) > 2 else '') if langs else '-' - languages_short.short_description = 'Языки' - - def technologies_short(self, obj): - techs = obj.get_technologies_list()[:3] - return ', '.join(techs) + ('...' if len(obj.get_technologies_list()) > 3 else '') - technologies_short.short_description = 'Технологии' - - -@admin.register(Skill) -class SkillAdmin(admin.ModelAdmin): - list_display = ['name', 'category', 'icon', 'order'] - list_filter = ['category'] - list_editable = ['order'] - search_fields = ['name'] - ordering = ['category', 'order'] - - -@admin.register(Experience) -class ExperienceAdmin(admin.ModelAdmin): - list_display = ['title', 'company', 'start_date', 'end_date', 'is_current', 'duration', 'order'] - list_filter = ['is_current', 'company'] - list_editable = ['order'] - search_fields = ['title', 'company', 'description'] - ordering = ['order', '-start_date'] - - -@admin.register(Education) -class EducationAdmin(admin.ModelAdmin): - list_display = ['institution_short_display', 'degree', 'education_type', 'start_year', 'end_year', 'is_current', 'order'] - list_filter = ['education_type', 'is_current'] - list_editable = ['order'] - search_fields = ['institution', 'degree'] - ordering = ['order', '-start_year'] - - def institution_short_display(self, obj): - return obj.institution_short or obj.institution[:30] + '...' - institution_short_display.short_description = 'Учреждение' - - -@admin.register(Comment) -class CommentAdmin(admin.ModelAdmin): - list_display = ['user', 'article', 'content_short', 'is_approved', 'created_at'] - list_filter = ['is_approved', 'created_at'] - search_fields = ['content', 'user__username', 'article__title'] - list_editable = ['is_approved'] - readonly_fields = ['created_at', 'updated_at'] - actions = ['approve_comments', 'reject_comments'] - - def content_short(self, obj): - return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content - content_short.short_description = 'Содержание' - - def approve_comments(self, request, queryset): - count = queryset.update(is_approved=True) - self.message_user(request, f'Одобрено {count} комментариев') - approve_comments.short_description = 'Одобрить выбранные комментарии' - - def reject_comments(self, request, queryset): - count = queryset.update(is_approved=False) - self.message_user(request, f'Отклонено {count} комментариев') - reject_comments.short_description = 'Отклонить выбранные комментарии' - - -@admin.register(ContactMessage) -class ContactMessageAdmin(admin.ModelAdmin): - list_display = ['name', 'email', 'subject', 'is_read', 'is_replied', 'created_at'] - list_filter = ['is_read', 'is_replied', 'created_at'] - search_fields = ['name', 'email', 'subject', 'message'] - readonly_fields = ['name', 'email', 'subject', 'message', 'created_at'] - list_editable = ['is_read', 'is_replied'] - ordering = ['-created_at'] - - fieldsets = ( - ('Сообщение', { - 'fields': ('name', 'email', 'subject', 'message', 'created_at') - }), - ('Статус', { - 'fields': ('is_read', 'is_replied', 'admin_notes') - }), - ) - - -@admin.register(SiteSettings) -class SiteSettingsAdmin(admin.ModelAdmin): - fieldsets = ( - ('Основное', { - 'fields': ('site_name', 'site_description') - }), - ('Владелец', { - 'fields': ('owner_name', 'owner_title', 'owner_bio', 'owner_photo') - }), - ('Контакты', { - 'fields': ('owner_email', 'owner_phone', 'owner_city') - }), - ('Социальные сети', { - 'fields': ('telegram_url', 'github_url', 'vk_url', 'linkedin_url') - }), - ('Файлы', { - 'fields': ('resume_file',) - }), - ('Аналитика', { - 'fields': ('yandex_metrika_id', 'google_analytics_id'), - 'classes': ('collapse',) - }), - ) - - def has_add_permission(self, request): - return not SiteSettings.objects.exists() - - def has_delete_permission(self, request, obj=None): - return False - - -admin.site.register(ArticleLike) -admin.site.register(CommentLike) - -admin.site.site_header = 'deev.space — Администрирование' -admin.site.site_title = 'deev.space Admin' -admin.site.index_title = 'Панель управления' \ No newline at end of file +import csv +import json + +from django.contrib import admin +from django.http import HttpResponse +from django.utils.html import format_html +from .models import ( + CustomUser, Category, Article, ArticleImage, ArticleFile, ArticleLink, + Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike, + ContactMessage, Experience, Education, SiteSettings +) + + +@admin.register(CustomUser) +class CustomUserAdmin(admin.ModelAdmin): + list_display = ['username', 'email', 'is_verified', 'is_active', 'date_joined'] + list_filter = ['is_verified', 'is_active', 'is_staff', 'date_joined'] + search_fields = ['username', 'email'] + ordering = ['-date_joined'] + readonly_fields = ['date_joined', 'last_login'] + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'icon', 'order', 'articles_count'] + list_editable = ['order'] + prepopulated_fields = {'slug': ('name',)} + search_fields = ['name'] + + def articles_count(self, obj): + return obj.articles_count + articles_count.short_description = 'Статей' + + +class ArticleImageInline(admin.TabularInline): + model = ArticleImage + extra = 1 + fields = ['image', 'caption', 'order', 'is_inline'] + ordering = ['order'] + + +class ArticleFileInline(admin.TabularInline): + model = ArticleFile + extra = 1 + fields = ['file', 'title', 'description', 'order', 'is_inline'] + ordering = ['order'] + + +class ArticleLinkInline(admin.TabularInline): + model = ArticleLink + extra = 1 + fields = ['url', 'title', 'description', 'preview_image', 'order', 'is_inline'] + ordering = ['order'] + readonly_fields = [] + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + list_display = ['title', 'category', 'is_published', 'comments_enabled', 'is_achievement', 'views', 'date', 'image_preview'] + list_filter = ['is_published', 'comments_enabled', 'is_achievement', 'category', 'date'] + search_fields = ['title', 'post', 'excerpt'] + prepopulated_fields = {'slug': ('title',)} + date_hierarchy = 'date' + list_editable = ['is_published', 'comments_enabled'] + readonly_fields = ['views', 'date', 'updated_at', 'image_preview_large', 'likes_summary'] + inlines = [ArticleImageInline, ArticleFileInline, ArticleLinkInline] + + fieldsets = ( + ('Основное', { + 'fields': ('title', 'slug', 'sub_title', 'excerpt', 'post') + }), + ('Превью (обложка)', { + 'fields': ('img', 'show_cover_in_article', 'image_preview_large') + }), + ('Галерея изображений', { + 'fields': ('gallery_display_mode',), + 'description': 'Добавьте изображения ниже в инлайн-форме "Изображения статьи"' + }), + ('Категоризация', { + 'fields': ('category', 'author') + }), + ('Комментарии и медиа', { + 'fields': ('comments_enabled', 'exclude_inline_from_blocks'), + 'description': 'Управление комментариями и отображением медиа-контента' + }), + ('Достижение', { + 'fields': ('is_achievement', 'achievement_icon', 'achievement_date'), + 'classes': ('collapse',) + }), + ('Публикация', { + 'fields': ('is_published', 'views', 'date', 'updated_at') + }), + ('Лайки и дизлайки', { + 'fields': ('likes_summary',), + }), + ) + + @admin.display(description='Оценки сейчас') + def likes_summary(self, obj): + if not obj.pk: + return '-' + return (f'лайки: {obj.likes_count}; дизлайки: {obj.dislikes_count}; ' + f'уникальных просмотров: {obj.unique_views.count()}') + + def image_preview(self, obj): + if obj.img: + return format_html('', obj.img.url) + return '-' + image_preview.short_description = 'Превью' + + def image_preview_large(self, obj): + if obj.img: + return format_html('', obj.img.url) + return 'Нет изображения' + image_preview_large.short_description = 'Изображение' + + +@admin.register(ProjectStatus) +class ProjectStatusAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'color', 'is_release', 'order'] + list_editable = ['order', 'is_release'] + prepopulated_fields = {'slug': ('name',)} + search_fields = ['name'] + ordering = ['order'] + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + list_display = ['title', 'status', 'card_size', 'languages_short', 'technologies_short', 'show_on_homepage', 'homepage_order', 'order', 'is_visible'] + list_filter = ['status', 'card_size', 'is_visible', 'show_on_homepage'] + search_fields = ['title', 'description', 'technologies', 'programming_languages'] + prepopulated_fields = {'slug': ('title',)} + list_editable = ['order', 'is_visible', 'card_size', 'status', 'show_on_homepage', 'homepage_order'] + actions = ['export_as_json', 'export_as_csv'] + + fieldsets = ( + ('Основное', { + 'fields': ('title', 'slug', 'short_description', 'description', 'features') + }), + ('Визуальное', { + 'fields': ('img_main', 'icon', 'card_size') + }), + ('Техническое', { + 'fields': ('programming_languages', 'technologies', 'github_url', 'demo_url'), + 'description': 'Языки программирования и технологии указываются через запятую' + }), + ('Статус и отображение', { + 'fields': ('status', 'users_count', 'is_visible', 'order') + }), + ('Главная страница', { + 'fields': ('show_on_homepage', 'homepage_order'), + 'description': 'Настройки отображения проекта на главной странице' + }), + ) + + def languages_short(self, obj): + langs = obj.get_languages_list()[:2] + return ', '.join(langs) + ('...' if len(obj.get_languages_list()) > 2 else '') if langs else '-' + languages_short.short_description = 'Языки' + + def technologies_short(self, obj): + techs = obj.get_technologies_list()[:3] + return ', '.join(techs) + ('...' if len(obj.get_technologies_list()) > 3 else '') + technologies_short.short_description = 'Технологии' + + def _project_row(self, obj): + return { + 'title': obj.title, + 'slug': obj.slug, + 'short_description': obj.short_description, + 'description': obj.description, + 'features': obj.features, + 'icon': obj.icon, + 'card_size': obj.get_card_size_display(), + 'programming_languages': obj.programming_languages, + 'technologies': obj.technologies, + 'github_url': obj.github_url, + 'demo_url': obj.demo_url, + 'status': obj.status.name if obj.status else '', + 'users_count': obj.users_count, + 'is_visible': obj.is_visible, + 'order': obj.order, + 'show_on_homepage': obj.show_on_homepage, + 'homepage_order': obj.homepage_order, + } + + def export_as_json(self, request, queryset): + rows = [self._project_row(obj) for obj in queryset] + response = HttpResponse( + json.dumps(rows, ensure_ascii=False, indent=2), + content_type='application/json; charset=utf-8', + ) + response['Content-Disposition'] = 'attachment; filename="projects_export.json"' + return response + export_as_json.short_description = 'Выгрузить выбранные проекты в JSON' + + def export_as_csv(self, request, queryset): + response = HttpResponse(content_type='text/csv; charset=utf-8-sig') + response['Content-Disposition'] = 'attachment; filename="projects_export.csv"' + fieldnames = list(self._project_row(queryset.first()).keys()) if queryset.exists() else [] + writer = csv.DictWriter(response, fieldnames=fieldnames) + writer.writeheader() + for obj in queryset: + writer.writerow(self._project_row(obj)) + return response + export_as_csv.short_description = 'Выгрузить выбранные проекты в CSV' + + +@admin.register(Skill) +class SkillAdmin(admin.ModelAdmin): + list_display = ['name', 'category', 'icon', 'order'] + list_filter = ['category'] + list_editable = ['order'] + search_fields = ['name'] + ordering = ['category', 'order'] + + +@admin.register(Experience) +class ExperienceAdmin(admin.ModelAdmin): + list_display = ['title', 'company', 'start_date', 'end_date', 'is_current', 'duration', 'order'] + list_filter = ['is_current', 'company'] + list_editable = ['order'] + search_fields = ['title', 'company', 'description'] + ordering = ['order', '-start_date'] + + +@admin.register(Education) +class EducationAdmin(admin.ModelAdmin): + list_display = ['institution_short_display', 'degree', 'education_type', 'start_year', 'end_year', 'is_current', 'order'] + list_filter = ['education_type', 'is_current'] + list_editable = ['order'] + search_fields = ['institution', 'degree'] + ordering = ['order', '-start_year'] + + def institution_short_display(self, obj): + return obj.institution_short or obj.institution[:30] + '...' + institution_short_display.short_description = 'Учреждение' + + +@admin.register(Comment) +class CommentAdmin(admin.ModelAdmin): + list_display = ['user', 'article', 'content_short', 'is_approved', 'created_at'] + list_filter = ['is_approved', 'created_at'] + search_fields = ['content', 'user__username', 'article__title'] + list_editable = ['is_approved'] + readonly_fields = ['created_at', 'updated_at'] + actions = ['approve_comments', 'reject_comments'] + + def content_short(self, obj): + return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content + content_short.short_description = 'Содержание' + + def approve_comments(self, request, queryset): + count = queryset.update(is_approved=True) + self.message_user(request, f'Одобрено {count} комментариев') + approve_comments.short_description = 'Одобрить выбранные комментарии' + + def reject_comments(self, request, queryset): + count = queryset.update(is_approved=False) + self.message_user(request, f'Отклонено {count} комментариев') + reject_comments.short_description = 'Отклонить выбранные комментарии' + + +@admin.register(ContactMessage) +class ContactMessageAdmin(admin.ModelAdmin): + list_display = ['name', 'email', 'subject', 'is_read', 'is_replied', 'created_at'] + list_filter = ['is_read', 'is_replied', 'created_at'] + search_fields = ['name', 'email', 'subject', 'message'] + readonly_fields = ['name', 'email', 'subject', 'message', 'created_at'] + list_editable = ['is_read', 'is_replied'] + ordering = ['-created_at'] + + fieldsets = ( + ('Сообщение', { + 'fields': ('name', 'email', 'subject', 'message', 'created_at') + }), + ('Статус', { + 'fields': ('is_read', 'is_replied', 'admin_notes') + }), + ) + + +@admin.register(SiteSettings) +class SiteSettingsAdmin(admin.ModelAdmin): + fieldsets = ( + ('Основное', { + 'fields': ('site_name', 'site_description') + }), + ('Владелец', { + 'fields': ('owner_name', 'owner_title', 'owner_bio', 'owner_photo') + }), + ('Контакты', { + 'fields': ('owner_email', 'owner_phone', 'owner_city') + }), + ('Социальные сети', { + 'fields': ('telegram_url', 'github_url', 'vk_url', 'linkedin_url') + }), + ('Файлы', { + 'fields': ('resume_file',) + }), + ('Главная — карточка справа', { + 'fields': ('open_to_work', 'work_format') + }), + ('Аналитика', { + 'fields': ('yandex_metrika_id', 'google_analytics_id'), + 'classes': ('collapse',) + }), + ) + + def has_add_permission(self, request): + return not SiteSettings.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + +admin.site.register(ArticleLike) +admin.site.register(CommentLike) + +admin.site.site_header = 'deev.space — Администрирование' +admin.site.site_title = 'deev.space Admin' +admin.site.index_title = 'Панель управления' diff --git a/main/apps.py b/main/apps.py index e946b3b..aeda886 100644 --- a/main/apps.py +++ b/main/apps.py @@ -1,10 +1,10 @@ -from django.apps import AppConfig - - -class MainConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'main' - verbose_name = 'Основное приложение' - - def ready(self): +from django.apps import AppConfig + + +class MainConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'main' + verbose_name = 'Основное приложение' + + def ready(self): import main.signals # noqa \ No newline at end of file diff --git a/main/context_processors.py b/main/context_processors.py index e1e91d7..20883f9 100644 --- a/main/context_processors.py +++ b/main/context_processors.py @@ -1,8 +1,8 @@ -from .models import SiteSettings - - -def site_settings(request): - """Глобальный контекст с настройками сайта.""" - return { - 'global_settings': SiteSettings.load() +from .models import SiteSettings + + +def site_settings(request): + """Глобальный контекст с настройками сайта.""" + return { + 'global_settings': SiteSettings.load() } \ No newline at end of file diff --git a/main/forms.py b/main/forms.py index 2a07641..b86cd02 100644 --- a/main/forms.py +++ b/main/forms.py @@ -1,251 +1,251 @@ -from django import forms -from django.contrib.auth.forms import AuthenticationForm -from django.core.validators import RegexValidator -from django.conf import settings -import bleach -import requests - -from .models import CustomUser, Comment, ContactMessage - - -class SmartCaptchaField(forms.CharField): - """Кастомное поле для Yandex SmartCaptcha.""" - - def __init__(self, *args, **kwargs): - kwargs.setdefault('required', True) - kwargs.setdefault('widget', forms.HiddenInput(attrs={'id': 'smart-captcha-token'})) - super().__init__(*args, **kwargs) - - def validate(self, value): - super().validate(value) - if not value: - raise forms.ValidationError('Пожалуйста, пройдите проверку капчи') - - # Валидация токена на сервере Yandex - if not self._verify_captcha(value): - raise forms.ValidationError('Проверка капчи не пройдена. Попробуйте снова.') - - def _verify_captcha(self, token): - """Проверка токена на сервере Yandex SmartCaptcha.""" - try: - response = requests.post( - 'https://smartcaptcha.yandexcloud.net/validate', - data={ - 'secret': settings.SMARTCAPTCHA_SERVER_KEY, - 'token': token, - }, - timeout=5 - ) - - # Рекомендация документации: при ошибке HTTP считать проверку успешной - if response.status_code != 200: - return True - - result = response.json() - return result.get('status') == 'ok' - - except requests.RequestException: - # При сетевой ошибке пропускаем, чтобы не блокировать пользователей - return True - - -class SmartCaptchaWidget(forms.Widget): - """Виджет для отображения Yandex SmartCaptcha.""" - - template_name = 'widgets/smartcaptcha.html' - - def __init__(self, attrs=None): - default_attrs = {'class': 'smart-captcha-container'} - if attrs: - default_attrs.update(attrs) - super().__init__(attrs=default_attrs) - - def get_context(self, name, value, attrs): - context = super().get_context(name, value, attrs) - context['client_key'] = settings.SMARTCAPTCHA_CLIENT_KEY - return context - - -class RegisterForm(forms.ModelForm): - """Форма регистрации пользователя.""" - username = forms.CharField( - max_length=30, - validators=[ - RegexValidator( - regex=r'^[a-zA-Z0-9_]+$', - message='Имя пользователя может содержать только латинские буквы, цифры и подчёркивания' - ) - ], - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Имя пользователя (латиница)', - 'autocomplete': 'username' - }), - label='Имя пользователя' - ) - - email = forms.EmailField( - required=False, - widget=forms.EmailInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Email (необязательно)', - 'autocomplete': 'email' - }), - label='Email' - ) - - password = forms.CharField( - min_length=4, - widget=forms.PasswordInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Пароль (минимум 4 символа)', - 'autocomplete': 'new-password' - }), - label='Пароль' - ) - - password_confirm = forms.CharField( - widget=forms.PasswordInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Подтвердите пароль', - 'autocomplete': 'new-password' - }), - label='Подтверждение пароля' - ) - - captcha = SmartCaptchaField(label='') - - class Meta: - model = CustomUser - fields = ['username', 'email'] - - def clean_username(self): - username = self.cleaned_data.get('username') - if CustomUser.objects.filter(username__iexact=username).exists(): - raise forms.ValidationError('Пользователь с таким именем уже существует') - return username - - def clean(self): - cleaned_data = super().clean() - password = cleaned_data.get('password') - password_confirm = cleaned_data.get('password_confirm') - - if password and password_confirm and password != password_confirm: - self.add_error('password_confirm', 'Пароли не совпадают') - - return cleaned_data - - def save(self, commit=True): - user = super().save(commit=False) - user.set_password(self.cleaned_data['password']) - user.is_verified = True - if commit: - user.save() - return user - - -class LoginForm(AuthenticationForm): - """Форма авторизации пользователя.""" - username = forms.CharField( - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Имя пользователя', - 'autocomplete': 'username' - }), - label='Имя пользователя' - ) - - password = forms.CharField( - widget=forms.PasswordInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Пароль', - 'autocomplete': 'current-password' - }), - label='Пароль' - ) - - remember_me = forms.BooleanField( - required=False, - initial=True, - widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), - label='Запомнить меня' - ) - - error_messages = { - 'invalid_login': 'Неверное имя пользователя или пароль', - 'inactive': 'Этот аккаунт деактивирован', - } - - -class CommentForm(forms.ModelForm): - """Форма добавления комментария.""" - content = forms.CharField( - max_length=2000, - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'placeholder': 'Напишите комментарий...', - 'rows': 3 - }), - label='' - ) - - class Meta: - model = Comment - fields = ['content'] - - def clean_content(self): - content = self.cleaned_data.get('content') - allowed_tags = ['b', 'i', 'u', 'em', 'strong', 'a', 'code', 'pre'] - allowed_attrs = {'a': ['href', 'title', 'rel']} - content = bleach.clean(content, tags=allowed_tags, attributes=allowed_attrs, strip=True) - content = bleach.linkify(content) - return content - - -class ContactForm(forms.ModelForm): - """Форма обратной связи.""" - name = forms.CharField( - max_length=100, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Ваше имя' - }), - label='Имя' - ) - - email = forms.EmailField( - widget=forms.EmailInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Email для ответа' - }), - label='Email' - ) - - subject = forms.CharField( - max_length=200, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': 'Тема сообщения' - }), - label='Тема' - ) - - message = forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'placeholder': 'Текст сообщения', - 'rows': 5 - }), - label='Сообщение' - ) - - captcha = SmartCaptchaField(label='') - - class Meta: - model = ContactMessage - fields = ['name', 'email', 'subject', 'message'] - - def clean_message(self): - message = self.cleaned_data.get('message') - message = bleach.clean(message, tags=[], strip=True) +from django import forms +from django.contrib.auth.forms import AuthenticationForm +from django.core.validators import RegexValidator +from django.conf import settings +import bleach +import requests + +from .models import CustomUser, Comment, ContactMessage + + +class SmartCaptchaField(forms.CharField): + """Кастомное поле для Yandex SmartCaptcha.""" + + def __init__(self, *args, **kwargs): + kwargs.setdefault('required', True) + kwargs.setdefault('widget', forms.HiddenInput(attrs={'id': 'smart-captcha-token'})) + super().__init__(*args, **kwargs) + + def validate(self, value): + super().validate(value) + if not value: + raise forms.ValidationError('Пожалуйста, пройдите проверку капчи') + + # Валидация токена на сервере Yandex + if not self._verify_captcha(value): + raise forms.ValidationError('Проверка капчи не пройдена. Попробуйте снова.') + + def _verify_captcha(self, token): + """Проверка токена на сервере Yandex SmartCaptcha.""" + try: + response = requests.post( + 'https://smartcaptcha.yandexcloud.net/validate', + data={ + 'secret': settings.SMARTCAPTCHA_SERVER_KEY, + 'token': token, + }, + timeout=5 + ) + + # Рекомендация документации: при ошибке HTTP считать проверку успешной + if response.status_code != 200: + return True + + result = response.json() + return result.get('status') == 'ok' + + except requests.RequestException: + # При сетевой ошибке пропускаем, чтобы не блокировать пользователей + return True + + +class SmartCaptchaWidget(forms.Widget): + """Виджет для отображения Yandex SmartCaptcha.""" + + template_name = 'widgets/smartcaptcha.html' + + def __init__(self, attrs=None): + default_attrs = {'class': 'smart-captcha-container'} + if attrs: + default_attrs.update(attrs) + super().__init__(attrs=default_attrs) + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context['client_key'] = settings.SMARTCAPTCHA_CLIENT_KEY + return context + + +class RegisterForm(forms.ModelForm): + """Форма регистрации пользователя.""" + username = forms.CharField( + max_length=30, + validators=[ + RegexValidator( + regex=r'^[a-zA-Z0-9_]+$', + message='Имя пользователя может содержать только латинские буквы, цифры и подчёркивания' + ) + ], + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Имя пользователя (латиница)', + 'autocomplete': 'username' + }), + label='Имя пользователя' + ) + + email = forms.EmailField( + required=False, + widget=forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Email (необязательно)', + 'autocomplete': 'email' + }), + label='Email' + ) + + password = forms.CharField( + min_length=4, + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Пароль (минимум 4 символа)', + 'autocomplete': 'new-password' + }), + label='Пароль' + ) + + password_confirm = forms.CharField( + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Подтвердите пароль', + 'autocomplete': 'new-password' + }), + label='Подтверждение пароля' + ) + + captcha = SmartCaptchaField(label='') + + class Meta: + model = CustomUser + fields = ['username', 'email'] + + def clean_username(self): + username = self.cleaned_data.get('username') + if CustomUser.objects.filter(username__iexact=username).exists(): + raise forms.ValidationError('Пользователь с таким именем уже существует') + return username + + def clean(self): + cleaned_data = super().clean() + password = cleaned_data.get('password') + password_confirm = cleaned_data.get('password_confirm') + + if password and password_confirm and password != password_confirm: + self.add_error('password_confirm', 'Пароли не совпадают') + + return cleaned_data + + def save(self, commit=True): + user = super().save(commit=False) + user.set_password(self.cleaned_data['password']) + user.is_verified = True + if commit: + user.save() + return user + + +class LoginForm(AuthenticationForm): + """Форма авторизации пользователя.""" + username = forms.CharField( + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Имя пользователя', + 'autocomplete': 'username' + }), + label='Имя пользователя' + ) + + password = forms.CharField( + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Пароль', + 'autocomplete': 'current-password' + }), + label='Пароль' + ) + + remember_me = forms.BooleanField( + required=False, + initial=True, + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), + label='Запомнить меня' + ) + + error_messages = { + 'invalid_login': 'Неверное имя пользователя или пароль', + 'inactive': 'Этот аккаунт деактивирован', + } + + +class CommentForm(forms.ModelForm): + """Форма добавления комментария.""" + content = forms.CharField( + max_length=2000, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'placeholder': 'Напишите комментарий...', + 'rows': 3 + }), + label='' + ) + + class Meta: + model = Comment + fields = ['content'] + + def clean_content(self): + content = self.cleaned_data.get('content') + allowed_tags = ['b', 'i', 'u', 'em', 'strong', 'a', 'code', 'pre'] + allowed_attrs = {'a': ['href', 'title', 'rel']} + content = bleach.clean(content, tags=allowed_tags, attributes=allowed_attrs, strip=True) + content = bleach.linkify(content) + return content + + +class ContactForm(forms.ModelForm): + """Форма обратной связи.""" + name = forms.CharField( + max_length=100, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Ваше имя' + }), + label='Имя' + ) + + email = forms.EmailField( + widget=forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Email для ответа' + }), + label='Email' + ) + + subject = forms.CharField( + max_length=200, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Тема сообщения' + }), + label='Тема' + ) + + message = forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'placeholder': 'Текст сообщения', + 'rows': 5 + }), + label='Сообщение' + ) + + captcha = SmartCaptchaField(label='') + + class Meta: + model = ContactMessage + fields = ['name', 'email', 'subject', 'message'] + + def clean_message(self): + message = self.cleaned_data.get('message') + message = bleach.clean(message, tags=[], strip=True) return message \ No newline at end of file diff --git a/main/management/commands/load_initial_data.py b/main/management/commands/load_initial_data.py new file mode 100644 index 0000000..6e9344f --- /dev/null +++ b/main/management/commands/load_initial_data.py @@ -0,0 +1,366 @@ +from django.core.management.base import BaseCommand +from main.models import ( + Skill, Experience, Education, Project, Category, SiteSettings, Article +) +from datetime import date + + +class Command(BaseCommand): + help = 'Загрузка начальных данных из резюме Деева Е.В.' + + def handle(self, *args, **options): + self.stdout.write('Загрузка начальных данных...\n') + + self.load_site_settings() + self.load_skills() + self.load_experiences() + self.load_educations() + self.load_projects() + self.load_categories() + self.load_achievements() + + self.stdout.write(self.style.SUCCESS('\n✓ Все данные успешно загружены!')) + + def load_site_settings(self): + self.stdout.write(' → Настройки сайта...') + + settings, created = SiteSettings.objects.get_or_create(pk=1) + settings.site_name = 'deev.space' + settings.site_description = 'Персональный сайт backend-разработчика. Портфолио проектов, технический блог и контактная информация.' + settings.owner_name = 'Деев Егор Викторович' + settings.owner_title = 'Backend Developer' + settings.owner_bio = '''Опытный разработчик с более чем 3-летним стажем программирования. Начинал профессиональный путь с обучения в Яндекс Лицее, что заложило фундамент для дальнейшего роста. Обладаю подтверждённым опытом руководства командой разработки и управления полным циклом проектов — от концепции до реализации. + +Специализируюсь на создании backend-решений, парсинге данных, автоматизации процессов и интеграции AI-компонентов. Отличаюсь аналитическим складом ума, нацелен на поиск и эффективное решение сложных, нестандартных задач.''' + settings.owner_email = 'egor@deev.space' + settings.owner_phone = '+7 (999) 373-77-37' + settings.owner_city = 'Москва' + settings.telegram_url = 'https://t.me/Egor_Deev' + settings.github_url = 'https://github.com/EDeev' + settings.save() + + self.stdout.write(self.style.SUCCESS(' OK')) + + def load_skills(self): + self.stdout.write(' → Навыки...') + + skills_data = [ + # Backend + ('Python', 'backend', 'devicon-python-plain', 1), + ('C', 'backend', 'devicon-c-plain', 2), + ('C++', 'backend', 'devicon-cplusplus-plain', 3), + # Frontend + ('HTML / CSS', 'frontend', 'devicon-html5-plain', 1), + ('Bootstrap', 'frontend', 'devicon-bootstrap-plain', 2), + ('Figma', 'frontend', 'devicon-figma-plain', 3), + # DevOps + ('Nginx', 'devops', 'devicon-nginx-original', 1), + ('Docker', 'devops', 'devicon-docker-plain', 2), + ('Kubernetes', 'devops', 'devicon-kubernetes-plain', 3), + ('GitLab', 'devops', 'devicon-gitlab-plain', 4), + ('Bash', 'devops', 'devicon-bash-plain', 5), + # Database + ('PostgreSQL', 'database', 'devicon-postgresql-plain', 1), + ('SQLite', 'database', 'devicon-sqlite-plain', 2), + ('Chroma', 'database', 'fas fa-database', 3), + # Tools + ('Git', 'tools', 'devicon-git-plain', 1), + ('WinSCP', 'tools', 'fas fa-server', 2), + ('QT', 'tools', 'devicon-qt-original', 3), + ('Django', 'tools', 'devicon-django-plain', 4), + ('Asyncio', 'tools', 'fas fa-bolt', 5), + ('Flowise', 'tools', 'fas fa-robot', 6), + ] + + for name, category, icon, order in skills_data: + Skill.objects.update_or_create( + name=name, + defaults={'category': category, 'icon': icon, 'order': order} + ) + + self.stdout.write(self.style.SUCCESS(f' OK ({len(skills_data)} навыков)')) + + def load_experiences(self): + self.stdout.write(' → Опыт работы...') + + experiences_data = [ + { + 'title': 'Тимлид университетского проекта', + 'company': 'МосПолитех', + 'description': 'Руковожу разработкой офисного пакета на основе исходного кода Apache OpenOffice с последующей специализацией под нужды российского бизнеса и государственного сектора России.', + 'responsibilities': '''Сборка из исходного кода на GitHub с последующими правками кода +Разработка backend-части с доработкой сырых функций OpenOffice +Управление и контроль за выполнением командой разработки поставленных задач +Создание и структуризация упрощённого и более специализированного нового интерфейса +Написание более подробной и понятной пользователю документации по работе с офисным пакетом''', + 'technologies': 'C++, Apache OpenOffice, Bash, Git', + 'start_date': date(2024, 1, 1), + 'is_current': True, + 'order': 1 + }, + { + 'title': 'Руководитель команды разработки', + 'company': 'МосПолитех', + 'description': 'Руководил разработкой скриптов и алгоритмов на Python по проекту расширения веб-доступности «EasyAccess».', + 'responsibilities': '''Разработка backend-части с обработкой API запросов на Django +Управление и контроль за выполнением командой разработки крупных задач +Создание структуры API запросов +Реализация запросов к БД на PostgreSQL +Настройка Docker''', + 'technologies': 'Python, Django, Docker, PostgreSQL', + 'start_date': date(2024, 1, 1), + 'end_date': date(2025, 1, 1), + 'is_current': False, + 'order': 2 + } + ] + + for exp_data in experiences_data: + Experience.objects.update_or_create( + title=exp_data['title'], + company=exp_data['company'], + defaults=exp_data + ) + + self.stdout.write(self.style.SUCCESS(f' OK ({len(experiences_data)} записей)')) + + def load_educations(self): + self.stdout.write(' → Образование...') + + educations_data = [ + { + 'institution': 'Московский Политехнический Университет', + 'institution_short': 'МосПолитех', + 'degree': 'Системная и программная инженерия', + 'education_type': 'university', + 'description': 'Высшее образование по направлению системной и программной инженерии.', + 'achievements': '''Изучение алгоритмов и структур данных +Практика с различными базовыми алгоритмами для низкоуровневых языков +Анализ математических алгоритмов +Изучение С++ в контексте системного программирования +Изучение PostgreSQL и структуры базы данных''', + 'start_year': 2024, + 'is_current': True, + 'icon': 'fas fa-university', + 'order': 1 + }, + { + 'institution': 'Рязанский Государственный Радиотехнический Университет', + 'institution_short': 'РГРТУ', + 'degree': 'Фундаментальная информатика и информационные технологии', + 'education_type': 'university', + 'description': 'Высшее образование в области информационных технологий.', + 'achievements': '''Создание, поддержка и администрирование информационно-коммуникационных систем и баз данных +Разработка и тестирование программного обеспечения +Управление информационными ресурсами +Формирование навыков алгоритмического мышления +Разработка оригинальных алгоритмов и интеллектуальные программные системы''', + 'start_year': 2022, + 'end_year': 2024, + 'is_current': False, + 'icon': 'fas fa-university', + 'order': 2 + }, + { + 'institution': 'Яндекс Лицей / Норильск IT-Cube', + 'institution_short': 'Яндекс Лицей', + 'degree': 'Основы промышленного программирования на Python', + 'education_type': 'course', + 'description': 'Продвинутый курс программирования на Python.', + 'achievements': '''Изучение сложных алгоритмов и решение неординарных задач +Работа с фреймворками: Pygame, PyQT, Request, Aiogram и др. +Изучение структуры и создания базы данных SQLite''', + 'start_year': 2021, + 'end_year': 2022, + 'certificate_number': '2202 54726', + 'is_current': False, + 'icon': 'fas fa-graduation-cap', + 'order': 3 + }, + { + 'institution': 'Яндекс Лицей / Норильск IT-Cube', + 'institution_short': 'Яндекс Лицей', + 'degree': 'Основы программирования на языке Python', + 'education_type': 'course', + 'description': 'Базовый курс программирования на Python.', + 'achievements': '''Обучение основам и базовым библиотекам +Изучение PEP8''', + 'start_year': 2020, + 'end_year': 2021, + 'certificate_number': '2101 54726', + 'is_current': False, + 'icon': 'fas fa-graduation-cap', + 'order': 4 + }, + { + 'institution': 'Stepik', + 'institution_short': 'Stepik', + 'degree': 'Введение в программирование (C++)', + 'education_type': 'course', + 'description': 'Онлайн-курс по основам программирования на C++.', + 'achievements': '''Практические основы программирования на С++ +Изучение базовых конструкций языка''', + 'start_year': 2022, + 'end_year': 2023, + 'certificate_number': '2759904', + 'is_current': False, + 'icon': 'fas fa-laptop-code', + 'order': 5 + }, + { + 'institution': 'Московский Политех', + 'institution_short': 'МосПолитех', + 'degree': 'Управление инвестиционными проектами', + 'education_type': 'course', + 'description': 'Дополнительное образование по управлению проектами.', + 'achievements': '''Обучение основам и продвинутым методам управления инвестиционными проектами +Разработка финансовых моделей +Управление рисками и анализ внутренней и внешней среды проектов +Расчёт бизнес-плана +Изучение «мсп.рф», «Яндекс.трекер» и других бизнес-инструментов''', + 'start_year': 2024, + 'is_current': True, + 'icon': 'fas fa-chart-line', + 'order': 6 + } + ] + + for edu_data in educations_data: + Education.objects.update_or_create( + institution=edu_data['institution'], + degree=edu_data['degree'], + defaults=edu_data + ) + + self.stdout.write(self.style.SUCCESS(f' OK ({len(educations_data)} записей)')) + + def load_projects(self): + self.stdout.write(' → Проекты...') + + projects_data = [ + { + 'title': 'Abobot', + 'slug': 'abobot', + 'short_description': 'Многофункциональный Telegram-бот для упоминания пользователей внутри чатов.', + 'description': 'Многофункциональный Telegram-бот для упоминания пользователей внутри чатов с продвинутой системой распознавания имён и обработки данных.', + 'features': '''9 команд, 8 текстовых ивентов и 4 нативные функции +Распознавание имён и запись в начальной форме +Запись и обработка данных о группах в обширную БД +Конвертация голосовых сообщений в текст при помощи фреймворка gTTS +Сложный алгоритм оповещения пользователя при упоминании имени в чате''', + 'icon': 'fab fa-telegram', + 'technologies': 'Python, Asyncio, SQLite, Aiogram, gTTS, Request', + 'github_url': 'https://github.com/EDeev/abobot', + 'status': 'beta', + 'card_size': 'featured', + 'users_count': '17.3k+', + 'order': 1 + }, + { + 'title': 'Compile Hub', + 'slug': 'compile-hub', + 'short_description': 'Высокопроизводительный асинхронный сервис для веб-IDE с поддержкой множества языков.', + 'description': 'Высокопроизводительный асинхронный сервис на Python для веб-IDE с поддержкой множества языков программирования.', + 'features': '''Асинхронная queue-based архитектура обработки +Мультиязычная компиляция (C++, Python, JavaScript) +Комплексная система безопасности и rate limiting +Встроенная метрика производительности в реальном времени +Полноценный user management с файловой системой''', + 'icon': 'fas fa-terminal', + 'technologies': 'Python, FastAPI, SQLite, Asyncio', + 'status': 'beta', + 'card_size': 'regular', + 'order': 2 + }, + { + 'title': 'Mobiles Dataset', + 'slug': 'mobiles-dataset', + 'short_description': 'Реляционная система на PostgreSQL для каталогизации смартфонов с интерфейсом на PyQT.', + 'description': 'База данных с приложением на Python. Реляционная система на PostgreSQL для каталогизации технических характеристик и ценовой аналитики смартфонов.', + 'features': '''Функциональный интерфейс на PyQT +Нормализованная структура из 5 взаимосвязанных таблиц +Реализованный полный CRUD функционал +Автоматизированный импорт из CSV с валидацией +Оптимизированные индексы для быстрого поиска''', + 'icon': 'fas fa-mobile-alt', + 'technologies': 'Python, PyQT, PostgreSQL, Psycopg2, Pandas', + 'status': 'completed', + 'card_size': 'regular', + 'order': 3 + }, + { + 'title': 'deev.space', + 'slug': 'deev-space', + 'short_description': 'Личный сайт с информацией обо мне, контактной информацией, достижениями и проектами.', + 'description': 'Личный сайт с информацией обо мне, контактной информацией, достижениями и вкладками с проектами.', + 'features': '''Развёртывание сервера на Nginx +Управление сайтом на Django +Разработка интерфейса с использованием Bootstrap и готовых темплейтов +Шаблонизация HTML кода с использованием Jinja логики +Привязка БД и метрик Яндекса''', + 'icon': 'fas fa-globe', + 'technologies': 'Python, Django, SQLite, Nginx, Bootstrap, Request', + 'demo_url': 'https://deev.space', + 'status': 'beta', + 'card_size': 'small', + 'users_count': '20 посещений/мес.', + 'order': 4 + } + ] + + for proj_data in projects_data: + Project.objects.update_or_create( + slug=proj_data['slug'], + defaults=proj_data + ) + + self.stdout.write(self.style.SUCCESS(f' OK ({len(projects_data)} проектов)')) + + def load_categories(self): + self.stdout.write(' → Категории блога...') + + categories = [ + ('Технические гайды', 'tech-guides', 'fas fa-book', 1), + ('Личные истории', 'personal', 'fas fa-user', 2), + ('Размышления', 'thoughts', 'fas fa-lightbulb', 3), + ('Достижения', 'achievements', 'fas fa-trophy', 4), + ] + + for name, slug, icon, order in categories: + Category.objects.update_or_create( + slug=slug, + defaults={'name': name, 'icon': icon, 'order': order} + ) + + self.stdout.write(self.style.SUCCESS(f' OK ({len(categories)} категорий)')) + + def load_achievements(self): + self.stdout.write(' → Достижения...') + + achievements_category = Category.objects.filter(slug='achievements').first() + + achievements_data = [ + { + 'title': 'Международный конкурс научно-практических работ', + 'slug': 'naukoemkie-tehnologii-2024', + 'sub_title': '2 место в номинации «Научно-исследовательская работа»', + 'excerpt': 'Международный конкурс научно-практических работ и проектов в рамках Международной конференции «Наукоёмкие технологии — основа современного цифрового промышленного производства»', + 'post': '''Получено 2 место в номинации «Научно-исследовательская работа» на Международном конкурсе научно-практических работ и проектов в рамках Международной конференции «Наукоёмкие технологии — основа современного цифрового промышленного производства». + +**Тема работы:** «Разработка веб-интерфейса с адаптивным дизайном для пользователей с особыми возможностями здоровья в системе 1С» + +Работа была посвящена созданию доступного веб-интерфейса, учитывающего потребности пользователей с ограниченными возможностями здоровья.''', + 'category': achievements_category, + 'is_achievement': True, + 'achievement_icon': 'fas fa-medal', + 'achievement_date': date(2024, 1, 1), + } + ] + + for ach_data in achievements_data: + Article.objects.update_or_create( + slug=ach_data['slug'], + defaults=ach_data + ) + + self.stdout.write(self.style.SUCCESS(f' OK ({len(achievements_data)} достижений)')) \ No newline at end of file diff --git a/main/management/commands/populate_demo.py b/main/management/commands/populate_demo.py new file mode 100644 index 0000000..5467b4e --- /dev/null +++ b/main/management/commands/populate_demo.py @@ -0,0 +1,150 @@ +""" +Команда для заполнения базы данных демо-данными. +Использование: python manage.py populate_demo +""" + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from main.models import ( + SiteSettings, Skill, Project, Article, ArticleCategory, + Experience, Education, Achievement, Comment +) +from datetime import date, timedelta +import random + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Заполняет базу данных демо-данными' + + def handle(self, *args, **options): + self.stdout.write('Создание демо-данных...') + + # Создание настроек сайта + settings, created = SiteSettings.objects.get_or_create( + pk=1, + defaults={ + 'site_name': 'deev.space', + 'site_description': 'Персональный сайт backend-разработчика', + 'owner_name': 'Деев Егор Викторович', + 'owner_title': 'Backend Developer', + 'owner_city': 'Москва', + 'owner_email': 'deev@example.com', + 'owner_phone': '+7 (999) 123-45-67', + 'owner_bio': 'Опытный backend-разработчик с более чем 3-летним стажем...', + 'telegram_url': 'https://t.me/Egor_Deev', + 'github_url': 'https://github.com/Egor-Deev', + } + ) + + # Создание навыков + skills_data = [ + {'name': 'Python', 'category': 'backend', 'icon': 'devicon-python-plain'}, + {'name': 'Django', 'category': 'backend', 'icon': 'devicon-django-plain'}, + {'name': 'FastAPI', 'category': 'backend', 'icon': 'fas fa-bolt'}, + {'name': 'PostgreSQL', 'category': 'database', 'icon': 'devicon-postgresql-plain'}, + {'name': 'Redis', 'category': 'database', 'icon': 'devicon-redis-plain'}, + {'name': 'Docker', 'category': 'devops', 'icon': 'devicon-docker-plain'}, + {'name': 'Git', 'category': 'devops', 'icon': 'devicon-git-plain'}, + {'name': 'Linux', 'category': 'devops', 'icon': 'devicon-linux-plain'}, + {'name': 'HTML/CSS', 'category': 'frontend', 'icon': 'devicon-html5-plain'}, + {'name': 'JavaScript', 'category': 'frontend', 'icon': 'devicon-javascript-plain'}, + ] + + for data in skills_data: + Skill.objects.get_or_create(**data) + + # Создание проектов + projects_data = [ + { + 'title': 'TaskMaster Pro', + 'slug': 'taskmaster-pro', + 'short_description': 'Система управления задачами с AI-рекомендациями', + 'technologies': 'Python, Django, PostgreSQL, Redis, Celery', + 'status': 'completed', + 'card_size': 'featured', + 'icon': 'fas fa-tasks', + 'is_featured': True, + }, + { + 'title': 'DataParser', + 'slug': 'dataparser', + 'short_description': 'Высоконагруженный парсер данных с автоматизацией', + 'technologies': 'Python, Scrapy, PostgreSQL, Docker', + 'status': 'completed', + 'card_size': 'regular', + 'icon': 'fas fa-database', + }, + { + 'title': 'API Gateway', + 'slug': 'api-gateway', + 'short_description': 'Микросервисный API Gateway с авторизацией', + 'technologies': 'Python, FastAPI, Redis, JWT', + 'status': 'in_development', + 'card_size': 'regular', + 'icon': 'fas fa-server', + }, + ] + + for data in projects_data: + Project.objects.get_or_create(slug=data['slug'], defaults=data) + + # Создание категорий статей + categories_data = [ + {'name': 'Python', 'slug': 'python', 'icon': 'devicon-python-plain'}, + {'name': 'Django', 'slug': 'django', 'icon': 'devicon-django-plain'}, + {'name': 'DevOps', 'slug': 'devops', 'icon': 'fas fa-server'}, + ] + + for data in categories_data: + ArticleCategory.objects.get_or_create(slug=data['slug'], defaults=data) + + # Создание статей + python_cat = ArticleCategory.objects.get(slug='python') + articles_data = [ + { + 'title': 'Асинхронное программирование в Python', + 'slug': 'async-python', + 'excerpt': 'Разбираем asyncio и современные подходы...', + 'post': '

Полный текст статьи об асинхронном программировании...

', + 'category': python_cat, + 'is_published': True, + }, + { + 'title': 'Оптимизация Django ORM', + 'slug': 'django-orm-optimization', + 'excerpt': 'Как ускорить запросы к базе данных...', + 'post': '

Полный текст статьи об оптимизации Django ORM...

', + 'category': ArticleCategory.objects.get(slug='django'), + 'is_published': True, + }, + ] + + for data in articles_data: + Article.objects.get_or_create(slug=data['slug'], defaults=data) + + # Создание опыта работы + Experience.objects.get_or_create( + company='Tech Company', + defaults={ + 'title': 'Backend Developer', + 'start_date': date(2022, 1, 1), + 'is_current': True, + 'description': 'Разработка backend-систем на Python/Django', + } + ) + + # Создание образования + Education.objects.get_or_create( + institution='Московский Политехнический Университет', + defaults={ + 'institution_short': 'МосПолитех', + 'degree': 'Информационные системы и технологии', + 'education_type': 'university', + 'start_year': 2021, + 'is_current': True, + } + ) + + self.stdout.write(self.style.SUCCESS('Демо-данные успешно созданы!')) \ No newline at end of file diff --git a/main/middleware.py b/main/middleware.py new file mode 100644 index 0000000..808f1bf --- /dev/null +++ b/main/middleware.py @@ -0,0 +1,50 @@ +"""Анонимный идентификатор посетителя (для оценок и уникальных просмотров без регистрации).""" +import hashlib +import ipaddress +import uuid + +from django.conf import settings + +COOKIE = 'dspace_vid' +SALT = 'dspace-visitor' +MAX_AGE = 365 * 24 * 3600 + + +class VisitorMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + vid = request.get_signed_cookie(COOKIE, default=None, salt=SALT) + fresh = not vid + if fresh: + vid = uuid.uuid4().hex + request.visitor_id = vid + response = self.get_response(request) + if fresh: + response.set_signed_cookie( + COOKIE, vid, salt=SALT, max_age=MAX_AGE, httponly=True, + secure=not settings.DEBUG, samesite='Lax' + ) + return response + + +def client_ip_hash(request): + """Хэш IP + User-Agent с солью; пустая строка, если реальный IP не виден (loopback/частная сеть).""" + ip = (request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR') or '').strip() + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return '' + if addr.is_loopback or addr.is_private: + return '' + ua = request.META.get('HTTP_USER_AGENT', '') + return hashlib.sha256(f'{settings.SECRET_KEY}|{ip}|{ua}'.encode()).hexdigest() + + +BOT_MARKERS = ('bot', 'spider', 'crawl', 'slurp', 'preview', 'curl', 'wget', 'python-requests', 'headless') + + +def is_bot(request): + ua = request.META.get('HTTP_USER_AGENT', '').lower() + return not ua or any(m in ua for m in BOT_MARKERS) diff --git a/main/migrations/0001_initial.py b/main/migrations/0001_initial.py index d8b6836..a3d6629 100644 --- a/main/migrations/0001_initial.py +++ b/main/migrations/0001_initial.py @@ -1,229 +1,229 @@ -from django.conf import settings -import django.contrib.auth.models -import django.contrib.auth.validators -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='CustomUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('email', models.EmailField(blank=True, max_length=254, null=True)), - ('is_verified', models.BooleanField(default=False)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'verbose_name': 'Пользователь', - 'verbose_name_plural': 'Пользователи', - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - migrations.CreateModel( - name='Article', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=200, verbose_name='Заголовок')), - ('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')), - ('sub_title', models.CharField(blank=True, max_length=300, verbose_name='Подзаголовок')), - ('post', models.TextField(verbose_name='Содержание')), - ('excerpt', models.TextField(blank=True, max_length=500, verbose_name='Краткое описание')), - ('img', models.ImageField(blank=True, null=True, upload_to='articles/', verbose_name='Изображение')), - ('author', models.CharField(default='Егор Деев', max_length=100, verbose_name='Автор')), - ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата публикации')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('views', models.PositiveIntegerField(default=0, verbose_name='Просмотры')), - ('is_published', models.BooleanField(default=True, verbose_name='Опубликовано')), - ('is_achievement', models.BooleanField(default=False, verbose_name='Достижение')), - ('achievement_icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка достижения')), - ], - options={ - 'verbose_name': 'Статья', - 'verbose_name_plural': 'Статьи', - 'ordering': ['-date'], - }, - ), - migrations.CreateModel( - name='Category', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='Название')), - ('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL')), - ], - options={ - 'verbose_name': 'Категория', - 'verbose_name_plural': 'Категории', - 'ordering': ['name'], - }, - ), - migrations.CreateModel( - name='Comment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('content', models.TextField(max_length=2000, verbose_name='Содержание')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), - ('is_approved', models.BooleanField(default=True, verbose_name='Одобрен')), - ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='main.article', verbose_name='Статья')), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='main.comment', verbose_name='Родительский комментарий')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), - ], - options={ - 'verbose_name': 'Комментарий', - 'verbose_name_plural': 'Комментарии', - 'ordering': ['created_at'], - }, - ), - migrations.CreateModel( - name='ContactMessage', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='Имя')), - ('email', models.EmailField(max_length=254, verbose_name='Email')), - ('subject', models.CharField(max_length=200, verbose_name='Тема')), - ('message', models.TextField(verbose_name='Сообщение')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), - ('is_read', models.BooleanField(default=False, verbose_name='Прочитано')), - ], - options={ - 'verbose_name': 'Сообщение', - 'verbose_name_plural': 'Сообщения', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='Education', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('institution', models.CharField(max_length=300, verbose_name='Учебное заведение')), - ('degree', models.CharField(max_length=200, verbose_name='Специальность')), - ('description', models.TextField(blank=True, verbose_name='Описание')), - ('start_year', models.PositiveIntegerField(verbose_name='Год начала')), - ('end_year', models.PositiveIntegerField(blank=True, null=True, verbose_name='Год окончания')), - ('is_current', models.BooleanField(default=False, verbose_name='В процессе')), - ('certificate', models.CharField(blank=True, max_length=100, verbose_name='Сертификат')), - ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), - ], - options={ - 'verbose_name': 'Образование', - 'verbose_name_plural': 'Образование', - 'ordering': ['order', '-start_year'], - }, - ), - migrations.CreateModel( - name='Experience', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=200, verbose_name='Должность')), - ('company', models.CharField(max_length=200, verbose_name='Компания')), - ('description', models.TextField(verbose_name='Описание')), - ('technologies', models.CharField(blank=True, max_length=500, verbose_name='Технологии')), - ('start_date', models.DateField(verbose_name='Дата начала')), - ('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')), - ('is_current', models.BooleanField(default=False, verbose_name='Текущее место')), - ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), - ], - options={ - 'verbose_name': 'Опыт работы', - 'verbose_name_plural': 'Опыт работы', - 'ordering': ['order', '-start_date'], - }, - ), - migrations.CreateModel( - name='Project', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=200, verbose_name='Название')), - ('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')), - ('description', models.TextField(verbose_name='Описание')), - ('short_description', models.CharField(max_length=300, verbose_name='Краткое описание')), - ('img_main', models.ImageField(blank=True, null=True, upload_to='projects/', verbose_name='Главное изображение')), - ('img_icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка проекта')), - ('technologies', models.CharField(max_length=500, verbose_name='Технологии (через запятую)')), - ('github_url', models.URLField(blank=True, verbose_name='GitHub')), - ('demo_url', models.URLField(blank=True, verbose_name='Демо')), - ('status', models.CharField(choices=[('completed', 'Завершён'), ('in_development', 'В разработке')], default='completed', max_length=20, verbose_name='Статус')), - ('users_count', models.CharField(blank=True, max_length=50, verbose_name='Количество пользователей')), - ('author', models.CharField(default='Егор Деев', max_length=100, verbose_name='Автор')), - ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')), - ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')), - ], - options={ - 'verbose_name': 'Проект', - 'verbose_name_plural': 'Проекты', - 'ordering': ['order', '-date'], - }, - ), - migrations.CreateModel( - name='Skill', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='Название')), - ('category', models.CharField(choices=[('frontend', 'Frontend'), ('backend', 'Backend'), ('devops', 'DevOps'), ('database', 'Data Base'), ('tools', 'Tools')], max_length=20, verbose_name='Категория')), - ('icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка')), - ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), - ], - options={ - 'verbose_name': 'Навык', - 'verbose_name_plural': 'Навыки', - 'ordering': ['category', 'order'], - }, - ), - migrations.AddField( - model_name='article', - name='category', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='main.category', verbose_name='Категория'), - ), - migrations.CreateModel( - name='CommentLike', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_like', models.BooleanField(verbose_name='Лайк')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), - ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_likes', to='main.comment', verbose_name='Комментарий')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), - ], - options={ - 'verbose_name': 'Оценка комментария', - 'verbose_name_plural': 'Оценки комментариев', - 'unique_together': {('comment', 'user')}, - }, - ), - migrations.CreateModel( - name='ArticleLike', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_like', models.BooleanField(verbose_name='Лайк')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), - ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='main.article', verbose_name='Статья')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), - ], - options={ - 'verbose_name': 'Оценка статьи', - 'verbose_name_plural': 'Оценки статей', - 'unique_together': {('article', 'user')}, - }, - ), - ] +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(blank=True, max_length=254, null=True)), + ('is_verified', models.BooleanField(default=False)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'Пользователь', + 'verbose_name_plural': 'Пользователи', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Заголовок')), + ('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')), + ('sub_title', models.CharField(blank=True, max_length=300, verbose_name='Подзаголовок')), + ('post', models.TextField(verbose_name='Содержание')), + ('excerpt', models.TextField(blank=True, max_length=500, verbose_name='Краткое описание')), + ('img', models.ImageField(blank=True, null=True, upload_to='articles/', verbose_name='Изображение')), + ('author', models.CharField(default='Егор Деев', max_length=100, verbose_name='Автор')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата публикации')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('views', models.PositiveIntegerField(default=0, verbose_name='Просмотры')), + ('is_published', models.BooleanField(default=True, verbose_name='Опубликовано')), + ('is_achievement', models.BooleanField(default=False, verbose_name='Достижение')), + ('achievement_icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка достижения')), + ], + options={ + 'verbose_name': 'Статья', + 'verbose_name_plural': 'Статьи', + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Название')), + ('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL')), + ], + options={ + 'verbose_name': 'Категория', + 'verbose_name_plural': 'Категории', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(max_length=2000, verbose_name='Содержание')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('is_approved', models.BooleanField(default=True, verbose_name='Одобрен')), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='main.article', verbose_name='Статья')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='main.comment', verbose_name='Родительский комментарий')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Комментарий', + 'verbose_name_plural': 'Комментарии', + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='ContactMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Имя')), + ('email', models.EmailField(max_length=254, verbose_name='Email')), + ('subject', models.CharField(max_length=200, verbose_name='Тема')), + ('message', models.TextField(verbose_name='Сообщение')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), + ('is_read', models.BooleanField(default=False, verbose_name='Прочитано')), + ], + options={ + 'verbose_name': 'Сообщение', + 'verbose_name_plural': 'Сообщения', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Education', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('institution', models.CharField(max_length=300, verbose_name='Учебное заведение')), + ('degree', models.CharField(max_length=200, verbose_name='Специальность')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('start_year', models.PositiveIntegerField(verbose_name='Год начала')), + ('end_year', models.PositiveIntegerField(blank=True, null=True, verbose_name='Год окончания')), + ('is_current', models.BooleanField(default=False, verbose_name='В процессе')), + ('certificate', models.CharField(blank=True, max_length=100, verbose_name='Сертификат')), + ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), + ], + options={ + 'verbose_name': 'Образование', + 'verbose_name_plural': 'Образование', + 'ordering': ['order', '-start_year'], + }, + ), + migrations.CreateModel( + name='Experience', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Должность')), + ('company', models.CharField(max_length=200, verbose_name='Компания')), + ('description', models.TextField(verbose_name='Описание')), + ('technologies', models.CharField(blank=True, max_length=500, verbose_name='Технологии')), + ('start_date', models.DateField(verbose_name='Дата начала')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')), + ('is_current', models.BooleanField(default=False, verbose_name='Текущее место')), + ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), + ], + options={ + 'verbose_name': 'Опыт работы', + 'verbose_name_plural': 'Опыт работы', + 'ordering': ['order', '-start_date'], + }, + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Название')), + ('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL')), + ('description', models.TextField(verbose_name='Описание')), + ('short_description', models.CharField(max_length=300, verbose_name='Краткое описание')), + ('img_main', models.ImageField(blank=True, null=True, upload_to='projects/', verbose_name='Главное изображение')), + ('img_icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка проекта')), + ('technologies', models.CharField(max_length=500, verbose_name='Технологии (через запятую)')), + ('github_url', models.URLField(blank=True, verbose_name='GitHub')), + ('demo_url', models.URLField(blank=True, verbose_name='Демо')), + ('status', models.CharField(choices=[('completed', 'Завершён'), ('in_development', 'В разработке')], default='completed', max_length=20, verbose_name='Статус')), + ('users_count', models.CharField(blank=True, max_length=50, verbose_name='Количество пользователей')), + ('author', models.CharField(default='Егор Деев', max_length=100, verbose_name='Автор')), + ('date', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')), + ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')), + ], + options={ + 'verbose_name': 'Проект', + 'verbose_name_plural': 'Проекты', + 'ordering': ['order', '-date'], + }, + ), + migrations.CreateModel( + name='Skill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Название')), + ('category', models.CharField(choices=[('frontend', 'Frontend'), ('backend', 'Backend'), ('devops', 'DevOps'), ('database', 'Data Base'), ('tools', 'Tools')], max_length=20, verbose_name='Категория')), + ('icon', models.CharField(blank=True, max_length=50, verbose_name='Иконка')), + ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), + ], + options={ + 'verbose_name': 'Навык', + 'verbose_name_plural': 'Навыки', + 'ordering': ['category', 'order'], + }, + ), + migrations.AddField( + model_name='article', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='main.category', verbose_name='Категория'), + ), + migrations.CreateModel( + name='CommentLike', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_like', models.BooleanField(verbose_name='Лайк')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), + ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_likes', to='main.comment', verbose_name='Комментарий')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Оценка комментария', + 'verbose_name_plural': 'Оценки комментариев', + 'unique_together': {('comment', 'user')}, + }, + ), + migrations.CreateModel( + name='ArticleLike', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_like', models.BooleanField(verbose_name='Лайк')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='main.article', verbose_name='Статья')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Оценка статьи', + 'verbose_name_plural': 'Оценки статей', + 'unique_together': {('article', 'user')}, + }, + ), + ] diff --git a/main/migrations/0002_sitesettings_alter_category_options_and_more.py b/main/migrations/0002_sitesettings_alter_category_options_and_more.py index 7391d9d..23a3e4b 100644 --- a/main/migrations/0002_sitesettings_alter_category_options_and_more.py +++ b/main/migrations/0002_sitesettings_alter_category_options_and_more.py @@ -1,217 +1,217 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='SiteSettings', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('site_name', models.CharField(default='deev.space', max_length=100, verbose_name='Название сайта')), - ('site_description', models.TextField(blank=True, verbose_name='Описание сайта')), - ('owner_name', models.CharField(default='Деев Егор Викторович', max_length=200, verbose_name='Имя владельца')), - ('owner_title', models.CharField(default='Backend Developer', max_length=200, verbose_name='Должность')), - ('owner_bio', models.TextField(blank=True, verbose_name='О себе')), - ('owner_photo', models.ImageField(blank=True, null=True, upload_to='site/', verbose_name='Фото владельца')), - ('owner_email', models.EmailField(default='egor@deev.space', max_length=254, verbose_name='Email')), - ('owner_phone', models.CharField(blank=True, max_length=20, verbose_name='Телефон')), - ('owner_city', models.CharField(default='Москва', max_length=100, verbose_name='Город')), - ('telegram_url', models.URLField(blank=True, verbose_name='Telegram')), - ('github_url', models.URLField(blank=True, verbose_name='GitHub')), - ('vk_url', models.URLField(blank=True, verbose_name='VKontakte')), - ('linkedin_url', models.URLField(blank=True, verbose_name='LinkedIn')), - ('resume_file', models.FileField(blank=True, null=True, upload_to='site/', verbose_name='Файл резюме')), - ('yandex_metrika_id', models.CharField(blank=True, max_length=20, verbose_name='ID Яндекс.Метрики')), - ('google_analytics_id', models.CharField(blank=True, max_length=20, verbose_name='ID Google Analytics')), - ], - options={ - 'verbose_name': 'Настройки сайта', - 'verbose_name_plural': 'Настройки сайта', - }, - ), - migrations.AlterModelOptions( - name='category', - options={'ordering': ['order', 'name'], 'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'}, - ), - migrations.AlterModelOptions( - name='customuser', - options={'ordering': ['-date_joined'], 'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'}, - ), - migrations.AlterModelOptions( - name='skill', - options={'ordering': ['category', 'order', 'name'], 'verbose_name': 'Навык', 'verbose_name_plural': 'Навыки'}, - ), - migrations.RemoveField( - model_name='education', - name='certificate', - ), - migrations.RemoveField( - model_name='project', - name='img_icon', - ), - migrations.AddField( - model_name='article', - name='achievement_date', - field=models.DateField(blank=True, null=True, verbose_name='Дата достижения'), - ), - migrations.AddField( - model_name='category', - name='description', - field=models.TextField(blank=True, verbose_name='Описание'), - ), - migrations.AddField( - model_name='category', - name='icon', - field=models.CharField(blank=True, default='fas fa-folder', max_length=50, verbose_name='Иконка'), - ), - migrations.AddField( - model_name='category', - name='order', - field=models.PositiveIntegerField(default=0, verbose_name='Порядок'), - ), - migrations.AddField( - model_name='contactmessage', - name='admin_notes', - field=models.TextField(blank=True, verbose_name='Заметки администратора'), - ), - migrations.AddField( - model_name='contactmessage', - name='is_replied', - field=models.BooleanField(default=False, verbose_name='Отвечено'), - ), - migrations.AddField( - model_name='customuser', - name='avatar', - field=models.ImageField(blank=True, null=True, upload_to='avatars/', verbose_name='Аватар'), - ), - migrations.AddField( - model_name='education', - name='achievements', - field=models.TextField(blank=True, verbose_name='Достижения (каждое с новой строки)'), - ), - migrations.AddField( - model_name='education', - name='certificate_number', - field=models.CharField(blank=True, max_length=100, verbose_name='Номер сертификата'), - ), - migrations.AddField( - model_name='education', - name='certificate_url', - field=models.URLField(blank=True, verbose_name='Ссылка на сертификат'), - ), - migrations.AddField( - model_name='education', - name='education_type', - field=models.CharField(choices=[('university', 'Высшее образование'), ('course', 'Курс/Сертификат'), ('school', 'Школа/Лицей')], default='university', max_length=20, verbose_name='Тип'), - ), - migrations.AddField( - model_name='education', - name='icon', - field=models.CharField(default='fas fa-graduation-cap', max_length=50, verbose_name='Иконка'), - ), - migrations.AddField( - model_name='education', - name='institution_short', - field=models.CharField(blank=True, max_length=100, verbose_name='Сокращённое название'), - ), - migrations.AddField( - model_name='experience', - name='company_url', - field=models.URLField(blank=True, verbose_name='Сайт компании'), - ), - migrations.AddField( - model_name='experience', - name='responsibilities', - field=models.TextField(blank=True, verbose_name='Обязанности (каждая с новой строки)'), - ), - migrations.AddField( - model_name='project', - name='card_size', - field=models.CharField(choices=[('featured', 'Главный (большой)'), ('regular', 'Обычный'), ('small', 'Компактный')], default='regular', max_length=20, verbose_name='Размер карточки'), - ), - migrations.AddField( - model_name='project', - name='features', - field=models.TextField(blank=True, verbose_name='Особенности (каждая с новой строки)'), - ), - migrations.AddField( - model_name='project', - name='icon', - field=models.CharField(default='fas fa-code', max_length=50, verbose_name='Иконка проекта'), - ), - migrations.AddField( - model_name='project', - name='is_visible', - field=models.BooleanField(default=True, verbose_name='Отображать'), - ), - migrations.AddField( - model_name='skill', - name='icon_svg', - field=models.TextField(blank=True, verbose_name='SVG иконка'), - ), - migrations.AlterField( - model_name='article', - name='achievement_icon', - field=models.CharField(blank=True, default='fas fa-trophy', max_length=50, verbose_name='Иконка достижения'), - ), - migrations.AlterField( - model_name='article', - name='is_achievement', - field=models.BooleanField(default=False, verbose_name='Это достижение'), - ), - migrations.AlterField( - model_name='customuser', - name='email', - field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'), - ), - migrations.AlterField( - model_name='customuser', - name='is_verified', - field=models.BooleanField(default=False, verbose_name='Верифицирован'), - ), - migrations.AlterField( - model_name='education', - name='degree', - field=models.CharField(max_length=200, verbose_name='Специальность/Программа'), - ), - migrations.AlterField( - model_name='education', - name='is_current', - field=models.BooleanField(default=False, verbose_name='В процессе обучения'), - ), - migrations.AlterField( - model_name='experience', - name='is_current', - field=models.BooleanField(default=False, verbose_name='Текущее место работы'), - ), - migrations.AlterField( - model_name='project', - name='demo_url', - field=models.URLField(blank=True, verbose_name='Демо/Ссылка'), - ), - migrations.AlterField( - model_name='project', - name='description', - field=models.TextField(verbose_name='Полное описание'), - ), - migrations.AlterField( - model_name='project', - name='status', - field=models.CharField(choices=[('completed', 'Завершён'), ('in_development', 'В разработке'), ('beta', 'Бета')], default='completed', max_length=20, verbose_name='Статус'), - ), - migrations.AlterField( - model_name='skill', - name='category', - field=models.CharField(choices=[('backend', 'Backend'), ('frontend', 'Frontend'), ('devops', 'DevOps'), ('database', 'Database'), ('tools', 'Tools')], max_length=20, verbose_name='Категория'), - ), - migrations.AlterField( - model_name='skill', - name='icon', - field=models.CharField(blank=True, max_length=100, verbose_name='CSS класс иконки'), - ), - ] +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('site_name', models.CharField(default='deev.space', max_length=100, verbose_name='Название сайта')), + ('site_description', models.TextField(blank=True, verbose_name='Описание сайта')), + ('owner_name', models.CharField(default='Деев Егор Викторович', max_length=200, verbose_name='Имя владельца')), + ('owner_title', models.CharField(default='Backend Developer', max_length=200, verbose_name='Должность')), + ('owner_bio', models.TextField(blank=True, verbose_name='О себе')), + ('owner_photo', models.ImageField(blank=True, null=True, upload_to='site/', verbose_name='Фото владельца')), + ('owner_email', models.EmailField(default='egor@deev.space', max_length=254, verbose_name='Email')), + ('owner_phone', models.CharField(blank=True, max_length=20, verbose_name='Телефон')), + ('owner_city', models.CharField(default='Москва', max_length=100, verbose_name='Город')), + ('telegram_url', models.URLField(blank=True, verbose_name='Telegram')), + ('github_url', models.URLField(blank=True, verbose_name='GitHub')), + ('vk_url', models.URLField(blank=True, verbose_name='VKontakte')), + ('linkedin_url', models.URLField(blank=True, verbose_name='LinkedIn')), + ('resume_file', models.FileField(blank=True, null=True, upload_to='site/', verbose_name='Файл резюме')), + ('yandex_metrika_id', models.CharField(blank=True, max_length=20, verbose_name='ID Яндекс.Метрики')), + ('google_analytics_id', models.CharField(blank=True, max_length=20, verbose_name='ID Google Analytics')), + ], + options={ + 'verbose_name': 'Настройки сайта', + 'verbose_name_plural': 'Настройки сайта', + }, + ), + migrations.AlterModelOptions( + name='category', + options={'ordering': ['order', 'name'], 'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'}, + ), + migrations.AlterModelOptions( + name='customuser', + options={'ordering': ['-date_joined'], 'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'}, + ), + migrations.AlterModelOptions( + name='skill', + options={'ordering': ['category', 'order', 'name'], 'verbose_name': 'Навык', 'verbose_name_plural': 'Навыки'}, + ), + migrations.RemoveField( + model_name='education', + name='certificate', + ), + migrations.RemoveField( + model_name='project', + name='img_icon', + ), + migrations.AddField( + model_name='article', + name='achievement_date', + field=models.DateField(blank=True, null=True, verbose_name='Дата достижения'), + ), + migrations.AddField( + model_name='category', + name='description', + field=models.TextField(blank=True, verbose_name='Описание'), + ), + migrations.AddField( + model_name='category', + name='icon', + field=models.CharField(blank=True, default='fas fa-folder', max_length=50, verbose_name='Иконка'), + ), + migrations.AddField( + model_name='category', + name='order', + field=models.PositiveIntegerField(default=0, verbose_name='Порядок'), + ), + migrations.AddField( + model_name='contactmessage', + name='admin_notes', + field=models.TextField(blank=True, verbose_name='Заметки администратора'), + ), + migrations.AddField( + model_name='contactmessage', + name='is_replied', + field=models.BooleanField(default=False, verbose_name='Отвечено'), + ), + migrations.AddField( + model_name='customuser', + name='avatar', + field=models.ImageField(blank=True, null=True, upload_to='avatars/', verbose_name='Аватар'), + ), + migrations.AddField( + model_name='education', + name='achievements', + field=models.TextField(blank=True, verbose_name='Достижения (каждое с новой строки)'), + ), + migrations.AddField( + model_name='education', + name='certificate_number', + field=models.CharField(blank=True, max_length=100, verbose_name='Номер сертификата'), + ), + migrations.AddField( + model_name='education', + name='certificate_url', + field=models.URLField(blank=True, verbose_name='Ссылка на сертификат'), + ), + migrations.AddField( + model_name='education', + name='education_type', + field=models.CharField(choices=[('university', 'Высшее образование'), ('course', 'Курс/Сертификат'), ('school', 'Школа/Лицей')], default='university', max_length=20, verbose_name='Тип'), + ), + migrations.AddField( + model_name='education', + name='icon', + field=models.CharField(default='fas fa-graduation-cap', max_length=50, verbose_name='Иконка'), + ), + migrations.AddField( + model_name='education', + name='institution_short', + field=models.CharField(blank=True, max_length=100, verbose_name='Сокращённое название'), + ), + migrations.AddField( + model_name='experience', + name='company_url', + field=models.URLField(blank=True, verbose_name='Сайт компании'), + ), + migrations.AddField( + model_name='experience', + name='responsibilities', + field=models.TextField(blank=True, verbose_name='Обязанности (каждая с новой строки)'), + ), + migrations.AddField( + model_name='project', + name='card_size', + field=models.CharField(choices=[('featured', 'Главный (большой)'), ('regular', 'Обычный'), ('small', 'Компактный')], default='regular', max_length=20, verbose_name='Размер карточки'), + ), + migrations.AddField( + model_name='project', + name='features', + field=models.TextField(blank=True, verbose_name='Особенности (каждая с новой строки)'), + ), + migrations.AddField( + model_name='project', + name='icon', + field=models.CharField(default='fas fa-code', max_length=50, verbose_name='Иконка проекта'), + ), + migrations.AddField( + model_name='project', + name='is_visible', + field=models.BooleanField(default=True, verbose_name='Отображать'), + ), + migrations.AddField( + model_name='skill', + name='icon_svg', + field=models.TextField(blank=True, verbose_name='SVG иконка'), + ), + migrations.AlterField( + model_name='article', + name='achievement_icon', + field=models.CharField(blank=True, default='fas fa-trophy', max_length=50, verbose_name='Иконка достижения'), + ), + migrations.AlterField( + model_name='article', + name='is_achievement', + field=models.BooleanField(default=False, verbose_name='Это достижение'), + ), + migrations.AlterField( + model_name='customuser', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'), + ), + migrations.AlterField( + model_name='customuser', + name='is_verified', + field=models.BooleanField(default=False, verbose_name='Верифицирован'), + ), + migrations.AlterField( + model_name='education', + name='degree', + field=models.CharField(max_length=200, verbose_name='Специальность/Программа'), + ), + migrations.AlterField( + model_name='education', + name='is_current', + field=models.BooleanField(default=False, verbose_name='В процессе обучения'), + ), + migrations.AlterField( + model_name='experience', + name='is_current', + field=models.BooleanField(default=False, verbose_name='Текущее место работы'), + ), + migrations.AlterField( + model_name='project', + name='demo_url', + field=models.URLField(blank=True, verbose_name='Демо/Ссылка'), + ), + migrations.AlterField( + model_name='project', + name='description', + field=models.TextField(verbose_name='Полное описание'), + ), + migrations.AlterField( + model_name='project', + name='status', + field=models.CharField(choices=[('completed', 'Завершён'), ('in_development', 'В разработке'), ('beta', 'Бета')], default='completed', max_length=20, verbose_name='Статус'), + ), + migrations.AlterField( + model_name='skill', + name='category', + field=models.CharField(choices=[('backend', 'Backend'), ('frontend', 'Frontend'), ('devops', 'DevOps'), ('database', 'Database'), ('tools', 'Tools')], max_length=20, verbose_name='Категория'), + ), + migrations.AlterField( + model_name='skill', + name='icon', + field=models.CharField(blank=True, max_length=100, verbose_name='CSS класс иконки'), + ), + ] diff --git a/main/migrations/0003_article_extensions.py b/main/migrations/0003_article_extensions.py index 63455c7..a2cd64f 100644 --- a/main/migrations/0003_article_extensions.py +++ b/main/migrations/0003_article_extensions.py @@ -8,6 +8,7 @@ class Migration(migrations.Migration): ] operations = [ + # ProjectStatus model migrations.CreateModel( name='ProjectStatus', fields=[ @@ -27,6 +28,7 @@ class Migration(migrations.Migration): }, ), + # ArticleImage model migrations.CreateModel( name='ArticleImage', fields=[ @@ -48,6 +50,7 @@ class Migration(migrations.Migration): }, ), + # ArticleFile model migrations.CreateModel( name='ArticleFile', fields=[ @@ -70,6 +73,7 @@ class Migration(migrations.Migration): }, ), + # ArticleLink model migrations.CreateModel( name='ArticleLink', fields=[ @@ -92,6 +96,8 @@ class Migration(migrations.Migration): 'ordering': ['order'], }, ), + + # Article new fields migrations.AddField( model_name='article', name='comments_enabled', @@ -115,6 +121,8 @@ class Migration(migrations.Migration): help_text='Если включено, изображения/файлы/ссылки, добавленные через редактор в текст, не будут отображаться в общих блоках', verbose_name='Не дублировать медиа из редактора в блоках'), ), + + # Project new fields migrations.AddField( model_name='project', name='show_on_homepage', @@ -131,6 +139,8 @@ class Migration(migrations.Migration): field=models.CharField(blank=True, help_text='Например: Python, JavaScript', max_length=200, verbose_name='Языки программирования (через запятую)'), ), + + # Change Project.status to ForeignKey migrations.RemoveField( model_name='project', name='status', diff --git a/main/migrations/0005_alter_sitesettings_owner_name_alter_skill_category.py b/main/migrations/0005_alter_sitesettings_owner_name_alter_skill_category.py new file mode 100644 index 0000000..1317e9a --- /dev/null +++ b/main/migrations/0005_alter_sitesettings_owner_name_alter_skill_category.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.27 on 2026-09-30 15:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0004_alter_article_img_alter_project_technologies'), + ] + + operations = [ + migrations.AlterField( + model_name='sitesettings', + name='owner_name', + field=models.CharField(default='Деев Егор', max_length=200, verbose_name='Имя владельца'), + ), + migrations.AlterField( + model_name='skill', + name='category', + field=models.CharField(choices=[('languages', 'Languages'), ('backend', 'Backend'), ('ai', 'AI / LLM'), ('database', 'Database'), ('devops', 'DevOps'), ('frontend', 'Frontend'), ('tools', 'Tools')], max_length=20, verbose_name='Категория'), + ), + ] diff --git a/main/migrations/0006_articleview_alter_articlelike_unique_together_and_more.py b/main/migrations/0006_articleview_alter_articlelike_unique_together_and_more.py new file mode 100644 index 0000000..69ef707 --- /dev/null +++ b/main/migrations/0006_articleview_alter_articlelike_unique_together_and_more.py @@ -0,0 +1,100 @@ +# Generated by Django 4.2.27 on 2026-10-01 09:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0005_alter_sitesettings_owner_name_alter_skill_category'), + ] + + operations = [ + migrations.CreateModel( + name='ArticleView', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('visitor_id', models.CharField(max_length=64, verbose_name='ID посетителя')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')), + ], + options={ + 'verbose_name': 'Уникальный просмотр', + 'verbose_name_plural': 'Уникальные просмотры', + }, + ), + migrations.AlterUniqueTogether( + name='articlelike', + unique_together=set(), + ), + migrations.AlterUniqueTogether( + name='commentlike', + unique_together=set(), + ), + migrations.AddField( + model_name='articlelike', + name='ip_hash', + field=models.CharField(blank=True, db_index=True, max_length=64, verbose_name='Хэш IP'), + ), + migrations.AddField( + model_name='articlelike', + name='visitor_id', + field=models.CharField(blank=True, db_index=True, max_length=64, verbose_name='ID посетителя'), + ), + migrations.AddField( + model_name='commentlike', + name='ip_hash', + field=models.CharField(blank=True, db_index=True, max_length=64, verbose_name='Хэш IP'), + ), + migrations.AddField( + model_name='commentlike', + name='visitor_id', + field=models.CharField(blank=True, db_index=True, max_length=64, verbose_name='ID посетителя'), + ), + migrations.AddField( + model_name='sitesettings', + name='open_to_work', + field=models.BooleanField(default=True, help_text='Показывать статус на главной', verbose_name='Открыт к предложениям'), + ), + migrations.AddField( + model_name='sitesettings', + name='work_format', + field=models.CharField(blank=True, default='Удалённо или гибрид, фриланс', max_length=100, verbose_name='Формат работы'), + ), + migrations.AlterField( + model_name='articlelike', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='article_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + ), + migrations.AlterField( + model_name='commentlike', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comment_likes', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + ), + migrations.AddConstraint( + model_name='articlelike', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('article', 'user'), name='uniq_articlelike_user'), + ), + migrations.AddConstraint( + model_name='articlelike', + constraint=models.UniqueConstraint(condition=models.Q(('visitor_id', ''), _negated=True), fields=('article', 'visitor_id'), name='uniq_articlelike_visitor'), + ), + migrations.AddConstraint( + model_name='commentlike', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('comment', 'user'), name='uniq_commentlike_user'), + ), + migrations.AddConstraint( + model_name='commentlike', + constraint=models.UniqueConstraint(condition=models.Q(('visitor_id', ''), _negated=True), fields=('comment', 'visitor_id'), name='uniq_commentlike_visitor'), + ), + migrations.AddField( + model_name='articleview', + name='article', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unique_views', to='main.article', verbose_name='Статья'), + ), + migrations.AlterUniqueTogether( + name='articleview', + unique_together={('article', 'visitor_id')}, + ), + ] diff --git a/main/models.py b/main/models.py index a0759ad..93ada4c 100644 --- a/main/models.py +++ b/main/models.py @@ -1,620 +1,656 @@ -from django.db import models -from django.contrib.auth.models import AbstractUser -from django.urls import reverse -from django.utils.text import slugify -from unidecode import unidecode -import requests -from bs4 import BeautifulSoup -from urllib.parse import urlparse - - -class CustomUser(AbstractUser): - """Расширенная модель пользователя с упрощённой регистрацией.""" - email = models.EmailField(blank=True, null=True, verbose_name='Email') - is_verified = models.BooleanField(default=False, verbose_name='Верифицирован') - avatar = models.ImageField(upload_to='avatars/', blank=True, null=True, verbose_name='Аватар') - - class Meta: - verbose_name = 'Пользователь' - verbose_name_plural = 'Пользователи' - ordering = ['-date_joined'] - - def __str__(self): - return self.username - - def get_avatar_letter(self): - return self.username[0].upper() if self.username else 'U' - - -class Category(models.Model): - """Категории для статей блога.""" - name = models.CharField(max_length=100, verbose_name='Название') - slug = models.SlugField(max_length=100, unique=True, verbose_name='URL') - description = models.TextField(blank=True, verbose_name='Описание') - icon = models.CharField(max_length=50, blank=True, default='fas fa-folder', verbose_name='Иконка') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - - class Meta: - verbose_name = 'Категория' - verbose_name_plural = 'Категории' - ordering = ['order', 'name'] - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(unidecode(self.name)) - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse('blog_category', kwargs={'category_slug': self.slug}) - - @property - def articles_count(self): - return self.articles.filter(is_published=True).count() - - -class Article(models.Model): - """Модель статей блога и достижений.""" - - GALLERY_DISPLAY_CHOICES = [ - ('carousel', 'Карусель'), - ('collage', 'Коллаж'), - ] - - title = models.CharField(max_length=200, verbose_name='Заголовок') - slug = models.SlugField(max_length=200, unique=True, verbose_name='URL') - sub_title = models.CharField(max_length=300, blank=True, verbose_name='Подзаголовок') - excerpt = models.TextField(max_length=500, blank=True, verbose_name='Краткое описание') - post = models.TextField(verbose_name='Содержание') - img = models.ImageField(upload_to='articles/', blank=True, null=True, verbose_name='Превью (обложка)') - show_cover_in_article = models.BooleanField(default=True, verbose_name='Показывать обложку в начале статьи') - gallery_display_mode = models.CharField( - max_length=20, - choices=GALLERY_DISPLAY_CHOICES, - default='carousel', - verbose_name='Режим отображения галереи' - ) - category = models.ForeignKey( - Category, on_delete=models.SET_NULL, null=True, blank=True, - related_name='articles', verbose_name='Категория' - ) - author = models.CharField(max_length=100, default='Егор Деев', verbose_name='Автор') - date = models.DateTimeField(auto_now_add=True, verbose_name='Дата публикации') - updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') - views = models.PositiveIntegerField(default=0, verbose_name='Просмотры') - is_published = models.BooleanField(default=True, verbose_name='Опубликовано') - comments_enabled = models.BooleanField(default=True, verbose_name='Включить комментарии') - exclude_inline_from_blocks = models.BooleanField( - default=False, - verbose_name='Не дублировать медиа из редактора в блоках', - help_text='Если включено, изображения/файлы/ссылки, добавленные через редактор в текст, не будут отображаться в общих блоках' - ) - is_achievement = models.BooleanField(default=False, verbose_name='Это достижение') - achievement_icon = models.CharField( - max_length=50, blank=True, default='fas fa-trophy', - verbose_name='Иконка достижения' - ) - achievement_date = models.DateField(blank=True, null=True, verbose_name='Дата достижения') - - class Meta: - verbose_name = 'Статья' - verbose_name_plural = 'Статьи' - ordering = ['-date'] - - def __str__(self): - return self.title - - def save(self, *args, **kwargs): - if not self.slug: - base_slug = slugify(unidecode(self.title)) - slug = base_slug - counter = 1 - while Article.objects.filter(slug=slug).exclude(pk=self.pk).exists(): - slug = f"{base_slug}-{counter}" - counter += 1 - self.slug = slug - if not self.excerpt and self.post: - self.excerpt = self.post[:300] + '...' if len(self.post) > 300 else self.post - super().save(*args, **kwargs) - - def get_absolute_url(self): - return reverse('article_detail', kwargs={'slug': self.slug}) - - @property - def likes_count(self): - return self.likes.filter(is_like=True).count() - - @property - def dislikes_count(self): - return self.likes.filter(is_like=False).count() - - @property - def comments_count(self): - return self.comments.filter(is_approved=True).count() - - -class ArticleImage(models.Model): - """Изображения для статьи (галерея).""" - article = models.ForeignKey( - Article, on_delete=models.CASCADE, - related_name='gallery_images', - verbose_name='Статья' - ) - image = models.ImageField(upload_to='articles/gallery/', verbose_name='Изображение') - caption = models.CharField(max_length=255, blank=True, verbose_name='Подпись') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - is_inline = models.BooleanField( - default=False, - verbose_name='Добавлено через редактор', - help_text='Отметьте, если это изображение вставлено в текст статьи' - ) - - class Meta: - verbose_name = 'Изображение статьи' - verbose_name_plural = 'Изображения статьи' - ordering = ['order'] - - def __str__(self): - return f"Изображение для {self.article.title}" - - -class ArticleFile(models.Model): - """Прикреплённые файлы к статье.""" - article = models.ForeignKey( - Article, on_delete=models.CASCADE, - related_name='attached_files', - verbose_name='Статья' - ) - file = models.FileField(upload_to='articles/files/', verbose_name='Файл') - title = models.CharField(max_length=255, verbose_name='Название файла') - description = models.TextField(blank=True, verbose_name='Описание') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - is_inline = models.BooleanField( - default=False, - verbose_name='Добавлено через редактор', - help_text='Отметьте, если ссылка на этот файл вставлена в текст статьи' - ) - - class Meta: - verbose_name = 'Файл статьи' - verbose_name_plural = 'Файлы статьи' - ordering = ['order'] - - def __str__(self): - return self.title - - @property - def file_extension(self): - return self.file.name.split('.')[-1].upper() if self.file else '' - - @property - def file_size(self): - if self.file: - size = self.file.size - if size < 1024: - return f"{size} B" - elif size < 1024 * 1024: - return f"{size / 1024:.1f} KB" - else: - return f"{size / (1024 * 1024):.1f} MB" - return '' - - -class ArticleLink(models.Model): - """Ссылки, прикреплённые к статье.""" - article = models.ForeignKey( - Article, on_delete=models.CASCADE, - related_name='attached_links', - verbose_name='Статья' - ) - url = models.URLField(verbose_name='URL') - title = models.CharField(max_length=255, blank=True, verbose_name='Заголовок') - description = models.TextField(blank=True, verbose_name='Описание') - preview_image = models.URLField(blank=True, verbose_name='URL изображения превью') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - is_inline = models.BooleanField( - default=False, - verbose_name='Добавлено через редактор', - help_text='Отметьте, если эта ссылка вставлена в текст статьи' - ) - - class Meta: - verbose_name = 'Ссылка статьи' - verbose_name_plural = 'Ссылки статьи' - ordering = ['order'] - - def __str__(self): - return self.title or self.url - - @property - def domain(self): - parsed = urlparse(self.url) - return parsed.netloc - - def fetch_preview(self): - """Автоматическое получение метаданных ссылки.""" - try: - headers = {'User-Agent': 'Mozilla/5.0 (compatible; ArticleBot/1.0)'} - response = requests.get(self.url, headers=headers, timeout=10) - response.raise_for_status() - soup = BeautifulSoup(response.text, 'html.parser') - - # Получаем заголовок - og_title = soup.find('meta', property='og:title') - if og_title: - self.title = og_title.get('content', '')[:255] - elif soup.title: - self.title = soup.title.string[:255] if soup.title.string else '' - - # Получаем описание - og_desc = soup.find('meta', property='og:description') - if og_desc: - self.description = og_desc.get('content', '') - else: - meta_desc = soup.find('meta', attrs={'name': 'description'}) - if meta_desc: - self.description = meta_desc.get('content', '') - - # Получаем изображение - og_image = soup.find('meta', property='og:image') - if og_image: - self.preview_image = og_image.get('content', '') - - except Exception: - pass - - def save(self, *args, **kwargs): - if not self.title: - self.fetch_preview() - super().save(*args, **kwargs) - - -class ProjectStatus(models.Model): - """Гибкие статусы проектов.""" - name = models.CharField(max_length=50, verbose_name='Название') - slug = models.SlugField(max_length=50, unique=True, verbose_name='Код') - color = models.CharField(max_length=20, default='primary', verbose_name='Цвет (CSS класс)') - is_release = models.BooleanField( - default=False, - verbose_name='Это релиз', - help_text='Проекты с этим статусом попадают в "Завершённые"' - ) - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - - class Meta: - verbose_name = 'Статус проекта' - verbose_name_plural = 'Статусы проектов' - ordering = ['order'] - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(unidecode(self.name)) - super().save(*args, **kwargs) - - -class Project(models.Model): - """Модель проектов портфолио.""" - SIZE_CHOICES = [ - ('featured', 'Главный (большой)'), - ('regular', 'Обычный'), - ('small', 'Компактный'), - ] - - title = models.CharField(max_length=200, verbose_name='Название') - slug = models.SlugField(max_length=200, unique=True, verbose_name='URL') - short_description = models.CharField(max_length=300, verbose_name='Краткое описание') - description = models.TextField(verbose_name='Полное описание') - features = models.TextField(blank=True, verbose_name='Особенности (каждая с новой строки)') - img_main = models.ImageField(upload_to='projects/', blank=True, null=True, verbose_name='Главное изображение') - icon = models.CharField(max_length=50, default='fas fa-code', verbose_name='Иконка проекта') - - # Разделение на языки и технологии - programming_languages = models.CharField( - max_length=200, - blank=True, - verbose_name='Языки программирования (через запятую)', - help_text='Например: Python, JavaScript' - ) - technologies = models.CharField( - max_length=500, - verbose_name='Технологии (через запятую)', - help_text='Например: Django, React, PostgreSQL' - ) - - github_url = models.URLField(blank=True, verbose_name='GitHub') - demo_url = models.URLField(blank=True, verbose_name='Демо/Ссылка') - - status = models.ForeignKey( - ProjectStatus, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name='Статус' - ) - card_size = models.CharField(max_length=20, choices=SIZE_CHOICES, default='regular', verbose_name='Размер карточки') - users_count = models.CharField(max_length=50, blank=True, verbose_name='Количество пользователей') - author = models.CharField(max_length=100, default='Егор Деев', verbose_name='Автор') - date = models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок отображения') - is_visible = models.BooleanField(default=True, verbose_name='Отображать') - - # Поля для главной страницы - show_on_homepage = models.BooleanField(default=False, verbose_name='Показывать на главной') - homepage_order = models.PositiveIntegerField(default=0, verbose_name='Порядок на главной') - - class Meta: - verbose_name = 'Проект' - verbose_name_plural = 'Проекты' - ordering = ['order', '-date'] - - def __str__(self): - return self.title - - def save(self, *args, **kwargs): - if not self.slug: - self.slug = slugify(unidecode(self.title)) - super().save(*args, **kwargs) - - def get_technologies_list(self): - return [tech.strip() for tech in self.technologies.split(',') if tech.strip()] - - def get_languages_list(self): - if not self.programming_languages: - return [] - return [lang.strip() for lang in self.programming_languages.split(',') if lang.strip()] - - def get_features_list(self): - if not self.features: - return [] - return [f.strip() for f in self.features.split('\n') if f.strip()] - - @property - def is_completed(self): - """Проект считается завершённым, если его статус помечен как релиз.""" - return self.status and self.status.is_release - - -class Skill(models.Model): - """Модель навыков и технологий.""" - CATEGORY_CHOICES = [ - ('backend', 'Backend'), - ('frontend', 'Frontend'), - ('devops', 'DevOps'), - ('database', 'Database'), - ('tools', 'Tools'), - ] - - name = models.CharField(max_length=100, verbose_name='Название') - category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, verbose_name='Категория') - icon = models.CharField(max_length=100, blank=True, verbose_name='CSS класс иконки') - icon_svg = models.TextField(blank=True, verbose_name='SVG иконка') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - - class Meta: - verbose_name = 'Навык' - verbose_name_plural = 'Навыки' - ordering = ['category', 'order', 'name'] - - def __str__(self): - return f"{self.name} ({self.get_category_display()})" - - -class Experience(models.Model): - """Модель опыта работы.""" - title = models.CharField(max_length=200, verbose_name='Должность') - company = models.CharField(max_length=200, verbose_name='Компания') - company_url = models.URLField(blank=True, verbose_name='Сайт компании') - description = models.TextField(verbose_name='Описание') - responsibilities = models.TextField(blank=True, verbose_name='Обязанности (каждая с новой строки)') - technologies = models.CharField(max_length=500, blank=True, verbose_name='Технологии') - start_date = models.DateField(verbose_name='Дата начала') - end_date = models.DateField(null=True, blank=True, verbose_name='Дата окончания') - is_current = models.BooleanField(default=False, verbose_name='Текущее место работы') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - - class Meta: - verbose_name = 'Опыт работы' - verbose_name_plural = 'Опыт работы' - ordering = ['order', '-start_date'] - - def __str__(self): - return f"{self.title} — {self.company}" - - def get_responsibilities_list(self): - if not self.responsibilities: - return [] - return [r.strip() for r in self.responsibilities.split('\n') if r.strip()] - - def get_technologies_list(self): - if not self.technologies: - return [] - return [t.strip() for t in self.technologies.split(',') if t.strip()] - - @property - def duration(self): - end = self.end_date or __import__('datetime').date.today() - months = (end.year - self.start_date.year) * 12 + (end.month - self.start_date.month) - years = months // 12 - remaining_months = months % 12 - - parts = [] - if years: - parts.append(f"{years} {'год' if years == 1 else 'года' if 2 <= years <= 4 else 'лет'}") - if remaining_months: - parts.append( - f"{remaining_months} {'месяц' if remaining_months == 1 else 'месяца' if 2 <= remaining_months <= 4 else 'месяцев'}") - return ' '.join(parts) if parts else 'Менее месяца' - - -class Education(models.Model): - """Модель образования.""" - TYPE_CHOICES = [ - ('university', 'Высшее образование'), - ('course', 'Курс/Сертификат'), - ('school', 'Школа/Лицей'), - ] - - institution = models.CharField(max_length=300, verbose_name='Учебное заведение') - institution_short = models.CharField(max_length=100, blank=True, verbose_name='Сокращённое название') - degree = models.CharField(max_length=200, verbose_name='Специальность/Программа') - education_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='university', verbose_name='Тип') - description = models.TextField(blank=True, verbose_name='Описание') - achievements = models.TextField(blank=True, verbose_name='Достижения (каждое с новой строки)') - start_year = models.PositiveIntegerField(verbose_name='Год начала') - end_year = models.PositiveIntegerField(null=True, blank=True, verbose_name='Год окончания') - is_current = models.BooleanField(default=False, verbose_name='В процессе обучения') - certificate_number = models.CharField(max_length=100, blank=True, verbose_name='Номер сертификата') - certificate_url = models.URLField(blank=True, verbose_name='Ссылка на сертификат') - icon = models.CharField(max_length=50, default='fas fa-graduation-cap', verbose_name='Иконка') - order = models.PositiveIntegerField(default=0, verbose_name='Порядок') - - class Meta: - verbose_name = 'Образование' - verbose_name_plural = 'Образование' - ordering = ['order', '-start_year'] - - def __str__(self): - return f"{self.degree} — {self.institution_short or self.institution}" - - def get_achievements_list(self): - if not self.achievements: - return [] - return [a.strip() for a in self.achievements.split('\n') if a.strip()] - - -class Comment(models.Model): - """Модель комментариев к статьям.""" - article = models.ForeignKey( - Article, on_delete=models.CASCADE, related_name='comments', verbose_name='Статья' - ) - user = models.ForeignKey( - CustomUser, on_delete=models.CASCADE, related_name='comments', verbose_name='Пользователь' - ) - parent = models.ForeignKey( - 'self', on_delete=models.CASCADE, null=True, blank=True, - related_name='replies', verbose_name='Родительский комментарий' - ) - content = models.TextField(max_length=2000, verbose_name='Содержание') - created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') - updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') - is_approved = models.BooleanField(default=True, verbose_name='Одобрен') - - class Meta: - verbose_name = 'Комментарий' - verbose_name_plural = 'Комментарии' - ordering = ['created_at'] - - def __str__(self): - return f'{self.user.username}: {self.content[:50]}...' - - @property - def likes_count(self): - return self.comment_likes.filter(is_like=True).count() - - @property - def dislikes_count(self): - return self.comment_likes.filter(is_like=False).count() - - @property - def nesting_level(self): - level = 0 - parent = self.parent - while parent and level < 3: - level += 1 - parent = parent.parent - return level - - -class ArticleLike(models.Model): - """Модель лайков/дизлайков статей.""" - article = models.ForeignKey( - Article, on_delete=models.CASCADE, related_name='likes', verbose_name='Статья' - ) - user = models.ForeignKey( - CustomUser, on_delete=models.CASCADE, related_name='article_likes', verbose_name='Пользователь' - ) - is_like = models.BooleanField(verbose_name='Лайк') - created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата') - - class Meta: - verbose_name = 'Оценка статьи' - verbose_name_plural = 'Оценки статей' - unique_together = ['article', 'user'] - - -class CommentLike(models.Model): - """Модель лайков/дизлайков комментариев.""" - comment = models.ForeignKey( - Comment, on_delete=models.CASCADE, related_name='comment_likes', verbose_name='Комментарий' - ) - user = models.ForeignKey( - CustomUser, on_delete=models.CASCADE, related_name='comment_likes', verbose_name='Пользователь' - ) - is_like = models.BooleanField(verbose_name='Лайк') - created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата') - - class Meta: - verbose_name = 'Оценка комментария' - verbose_name_plural = 'Оценки комментариев' - unique_together = ['comment', 'user'] - - -class ContactMessage(models.Model): - """Модель сообщений обратной связи.""" - name = models.CharField(max_length=100, verbose_name='Имя') - email = models.EmailField(verbose_name='Email') - subject = models.CharField(max_length=200, verbose_name='Тема') - message = models.TextField(verbose_name='Сообщение') - created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата') - is_read = models.BooleanField(default=False, verbose_name='Прочитано') - is_replied = models.BooleanField(default=False, verbose_name='Отвечено') - admin_notes = models.TextField(blank=True, verbose_name='Заметки администратора') - - class Meta: - verbose_name = 'Сообщение' - verbose_name_plural = 'Сообщения' - ordering = ['-created_at'] - - def __str__(self): - return f'{self.name}: {self.subject}' - - -class SiteSettings(models.Model): - """Настройки сайта (Singleton).""" - site_name = models.CharField(max_length=100, default='deev.space', verbose_name='Название сайта') - site_description = models.TextField(blank=True, verbose_name='Описание сайта') - owner_name = models.CharField(max_length=200, default='Деев Егор Викторович', verbose_name='Имя владельца') - owner_title = models.CharField(max_length=200, default='Backend Developer', verbose_name='Должность') - owner_bio = models.TextField(blank=True, verbose_name='О себе') - owner_photo = models.ImageField(upload_to='site/', blank=True, null=True, verbose_name='Фото владельца') - owner_email = models.EmailField(default='egor@deev.space', verbose_name='Email') - owner_phone = models.CharField(max_length=20, blank=True, verbose_name='Телефон') - owner_city = models.CharField(max_length=100, default='Москва', verbose_name='Город') - telegram_url = models.URLField(blank=True, verbose_name='Telegram') - github_url = models.URLField(blank=True, verbose_name='GitHub') - vk_url = models.URLField(blank=True, verbose_name='VKontakte') - linkedin_url = models.URLField(blank=True, verbose_name='LinkedIn') - resume_file = models.FileField(upload_to='site/', blank=True, null=True, verbose_name='Файл резюме') - yandex_metrika_id = models.CharField(max_length=20, blank=True, verbose_name='ID Яндекс.Метрики') - google_analytics_id = models.CharField(max_length=20, blank=True, verbose_name='ID Google Analytics') - - class Meta: - verbose_name = 'Настройки сайта' - verbose_name_plural = 'Настройки сайта' - - def __str__(self): - return self.site_name - - def save(self, *args, **kwargs): - self.pk = 1 - super().save(*args, **kwargs) - - @classmethod - def load(cls): - obj, created = cls.objects.get_or_create(pk=1) +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.urls import reverse +from django.utils.text import slugify +from unidecode import unidecode +import requests +from bs4 import BeautifulSoup +from urllib.parse import urlparse + + +class CustomUser(AbstractUser): + """Расширенная модель пользователя с упрощённой регистрацией.""" + email = models.EmailField(blank=True, null=True, verbose_name='Email') + is_verified = models.BooleanField(default=False, verbose_name='Верифицирован') + avatar = models.ImageField(upload_to='avatars/', blank=True, null=True, verbose_name='Аватар') + + class Meta: + verbose_name = 'Пользователь' + verbose_name_plural = 'Пользователи' + ordering = ['-date_joined'] + + def __str__(self): + return self.username + + def get_avatar_letter(self): + return self.username[0].upper() if self.username else 'U' + + +class Category(models.Model): + """Категории для статей блога.""" + name = models.CharField(max_length=100, verbose_name='Название') + slug = models.SlugField(max_length=100, unique=True, verbose_name='URL') + description = models.TextField(blank=True, verbose_name='Описание') + icon = models.CharField(max_length=50, blank=True, default='fas fa-folder', verbose_name='Иконка') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + + class Meta: + verbose_name = 'Категория' + verbose_name_plural = 'Категории' + ordering = ['order', 'name'] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(unidecode(self.name)) + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse('blog_category', kwargs={'category_slug': self.slug}) + + @property + def articles_count(self): + return self.articles.filter(is_published=True).count() + + +class Article(models.Model): + """Модель статей блога и достижений.""" + + GALLERY_DISPLAY_CHOICES = [ + ('carousel', 'Карусель'), + ('collage', 'Коллаж'), + ] + + title = models.CharField(max_length=200, verbose_name='Заголовок') + slug = models.SlugField(max_length=200, unique=True, verbose_name='URL') + sub_title = models.CharField(max_length=300, blank=True, verbose_name='Подзаголовок') + excerpt = models.TextField(max_length=500, blank=True, verbose_name='Краткое описание') + post = models.TextField(verbose_name='Содержание') + img = models.ImageField(upload_to='articles/', blank=True, null=True, verbose_name='Превью (обложка)') + show_cover_in_article = models.BooleanField(default=True, verbose_name='Показывать обложку в начале статьи') + gallery_display_mode = models.CharField( + max_length=20, + choices=GALLERY_DISPLAY_CHOICES, + default='carousel', + verbose_name='Режим отображения галереи' + ) + category = models.ForeignKey( + Category, on_delete=models.SET_NULL, null=True, blank=True, + related_name='articles', verbose_name='Категория' + ) + author = models.CharField(max_length=100, default='Егор Деев', verbose_name='Автор') + date = models.DateTimeField(auto_now_add=True, verbose_name='Дата публикации') + updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') + views = models.PositiveIntegerField(default=0, verbose_name='Просмотры') + is_published = models.BooleanField(default=True, verbose_name='Опубликовано') + comments_enabled = models.BooleanField(default=True, verbose_name='Включить комментарии') + exclude_inline_from_blocks = models.BooleanField( + default=False, + verbose_name='Не дублировать медиа из редактора в блоках', + help_text='Если включено, изображения/файлы/ссылки, добавленные через редактор в текст, не будут отображаться в общих блоках' + ) + is_achievement = models.BooleanField(default=False, verbose_name='Это достижение') + achievement_icon = models.CharField( + max_length=50, blank=True, default='fas fa-trophy', + verbose_name='Иконка достижения' + ) + achievement_date = models.DateField(blank=True, null=True, verbose_name='Дата достижения') + + class Meta: + verbose_name = 'Статья' + verbose_name_plural = 'Статьи' + ordering = ['-date'] + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(unidecode(self.title)) + slug = base_slug + counter = 1 + while Article.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + if not self.excerpt and self.post: + self.excerpt = self.post[:300] + '...' if len(self.post) > 300 else self.post + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse('article_detail', kwargs={'slug': self.slug}) + + @property + def likes_count(self): + return self.likes.filter(is_like=True).count() + + @property + def dislikes_count(self): + return self.likes.filter(is_like=False).count() + + @property + def comments_count(self): + return self.comments.filter(is_approved=True).count() + + +class ArticleImage(models.Model): + """Изображения для статьи (галерея).""" + article = models.ForeignKey( + Article, on_delete=models.CASCADE, + related_name='gallery_images', + verbose_name='Статья' + ) + image = models.ImageField(upload_to='articles/gallery/', verbose_name='Изображение') + caption = models.CharField(max_length=255, blank=True, verbose_name='Подпись') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + is_inline = models.BooleanField( + default=False, + verbose_name='Добавлено через редактор', + help_text='Отметьте, если это изображение вставлено в текст статьи' + ) + + class Meta: + verbose_name = 'Изображение статьи' + verbose_name_plural = 'Изображения статьи' + ordering = ['order'] + + def __str__(self): + return f"Изображение для {self.article.title}" + + +class ArticleFile(models.Model): + """Прикреплённые файлы к статье.""" + article = models.ForeignKey( + Article, on_delete=models.CASCADE, + related_name='attached_files', + verbose_name='Статья' + ) + file = models.FileField(upload_to='articles/files/', verbose_name='Файл') + title = models.CharField(max_length=255, verbose_name='Название файла') + description = models.TextField(blank=True, verbose_name='Описание') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + is_inline = models.BooleanField( + default=False, + verbose_name='Добавлено через редактор', + help_text='Отметьте, если ссылка на этот файл вставлена в текст статьи' + ) + + class Meta: + verbose_name = 'Файл статьи' + verbose_name_plural = 'Файлы статьи' + ordering = ['order'] + + def __str__(self): + return self.title + + @property + def file_extension(self): + return self.file.name.split('.')[-1].upper() if self.file else '' + + @property + def file_size(self): + if self.file: + size = self.file.size + if size < 1024: + return f"{size} B" + elif size < 1024 * 1024: + return f"{size / 1024:.1f} KB" + else: + return f"{size / (1024 * 1024):.1f} MB" + return '' + + +class ArticleLink(models.Model): + """Ссылки, прикреплённые к статье.""" + article = models.ForeignKey( + Article, on_delete=models.CASCADE, + related_name='attached_links', + verbose_name='Статья' + ) + url = models.URLField(verbose_name='URL') + title = models.CharField(max_length=255, blank=True, verbose_name='Заголовок') + description = models.TextField(blank=True, verbose_name='Описание') + preview_image = models.URLField(blank=True, verbose_name='URL изображения превью') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + is_inline = models.BooleanField( + default=False, + verbose_name='Добавлено через редактор', + help_text='Отметьте, если эта ссылка вставлена в текст статьи' + ) + + class Meta: + verbose_name = 'Ссылка статьи' + verbose_name_plural = 'Ссылки статьи' + ordering = ['order'] + + def __str__(self): + return self.title or self.url + + @property + def domain(self): + parsed = urlparse(self.url) + return parsed.netloc + + def fetch_preview(self): + """Автоматическое получение метаданных ссылки.""" + try: + headers = {'User-Agent': 'Mozilla/5.0 (compatible; ArticleBot/1.0)'} + response = requests.get(self.url, headers=headers, timeout=10) + response.raise_for_status() + soup = BeautifulSoup(response.text, 'html.parser') + + # Получаем заголовок + og_title = soup.find('meta', property='og:title') + if og_title: + self.title = og_title.get('content', '')[:255] + elif soup.title: + self.title = soup.title.string[:255] if soup.title.string else '' + + # Получаем описание + og_desc = soup.find('meta', property='og:description') + if og_desc: + self.description = og_desc.get('content', '') + else: + meta_desc = soup.find('meta', attrs={'name': 'description'}) + if meta_desc: + self.description = meta_desc.get('content', '') + + # Получаем изображение + og_image = soup.find('meta', property='og:image') + if og_image: + self.preview_image = og_image.get('content', '') + + except Exception: + pass + + def save(self, *args, **kwargs): + if not self.title: + self.fetch_preview() + super().save(*args, **kwargs) + + +class ProjectStatus(models.Model): + """Гибкие статусы проектов.""" + name = models.CharField(max_length=50, verbose_name='Название') + slug = models.SlugField(max_length=50, unique=True, verbose_name='Код') + color = models.CharField(max_length=20, default='primary', verbose_name='Цвет (CSS класс)') + is_release = models.BooleanField( + default=False, + verbose_name='Это релиз', + help_text='Проекты с этим статусом попадают в "Завершённые"' + ) + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + + class Meta: + verbose_name = 'Статус проекта' + verbose_name_plural = 'Статусы проектов' + ordering = ['order'] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(unidecode(self.name)) + super().save(*args, **kwargs) + + +class Project(models.Model): + """Модель проектов портфолио.""" + SIZE_CHOICES = [ + ('featured', 'Главный (большой)'), + ('regular', 'Обычный'), + ('small', 'Компактный'), + ] + + title = models.CharField(max_length=200, verbose_name='Название') + slug = models.SlugField(max_length=200, unique=True, verbose_name='URL') + short_description = models.CharField(max_length=300, verbose_name='Краткое описание') + description = models.TextField(verbose_name='Полное описание') + features = models.TextField(blank=True, verbose_name='Особенности (каждая с новой строки)') + img_main = models.ImageField(upload_to='projects/', blank=True, null=True, verbose_name='Главное изображение') + icon = models.CharField(max_length=50, default='fas fa-code', verbose_name='Иконка проекта') + + # Разделение на языки и технологии + programming_languages = models.CharField( + max_length=200, + blank=True, + verbose_name='Языки программирования (через запятую)', + help_text='Например: Python, JavaScript' + ) + technologies = models.CharField( + max_length=500, + verbose_name='Технологии (через запятую)', + help_text='Например: Django, React, PostgreSQL' + ) + + github_url = models.URLField(blank=True, verbose_name='GitHub') + demo_url = models.URLField(blank=True, verbose_name='Демо/Ссылка') + + status = models.ForeignKey( + ProjectStatus, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name='Статус' + ) + card_size = models.CharField(max_length=20, choices=SIZE_CHOICES, default='regular', verbose_name='Размер карточки') + users_count = models.CharField(max_length=50, blank=True, verbose_name='Количество пользователей') + author = models.CharField(max_length=100, default='Егор Деев', verbose_name='Автор') + date = models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок отображения') + is_visible = models.BooleanField(default=True, verbose_name='Отображать') + + # Поля для главной страницы + show_on_homepage = models.BooleanField(default=False, verbose_name='Показывать на главной') + homepage_order = models.PositiveIntegerField(default=0, verbose_name='Порядок на главной') + + class Meta: + verbose_name = 'Проект' + verbose_name_plural = 'Проекты' + ordering = ['order', '-date'] + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(unidecode(self.title)) + super().save(*args, **kwargs) + + def get_technologies_list(self): + return [tech.strip() for tech in self.technologies.split(',') if tech.strip()] + + def get_languages_list(self): + if not self.programming_languages: + return [] + return [lang.strip() for lang in self.programming_languages.split(',') if lang.strip()] + + def get_features_list(self): + if not self.features: + return [] + return [f.strip() for f in self.features.split('\n') if f.strip()] + + @property + def is_completed(self): + """Проект считается завершённым, если его статус помечен как релиз.""" + return self.status and self.status.is_release + + +class Skill(models.Model): + """Модель навыков и технологий.""" + CATEGORY_CHOICES = [ + ('languages', 'Languages'), + ('backend', 'Backend'), + ('ai', 'AI / LLM'), + ('database', 'Database'), + ('devops', 'DevOps'), + ('frontend', 'Frontend'), + ('tools', 'Tools'), + ] + + name = models.CharField(max_length=100, verbose_name='Название') + category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, verbose_name='Категория') + icon = models.CharField(max_length=100, blank=True, verbose_name='CSS класс иконки') + icon_svg = models.TextField(blank=True, verbose_name='SVG иконка') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + + class Meta: + verbose_name = 'Навык' + verbose_name_plural = 'Навыки' + ordering = ['category', 'order', 'name'] + + def __str__(self): + return f"{self.name} ({self.get_category_display()})" + + +class Experience(models.Model): + """Модель опыта работы.""" + title = models.CharField(max_length=200, verbose_name='Должность') + company = models.CharField(max_length=200, verbose_name='Компания') + company_url = models.URLField(blank=True, verbose_name='Сайт компании') + description = models.TextField(verbose_name='Описание') + responsibilities = models.TextField(blank=True, verbose_name='Обязанности (каждая с новой строки)') + technologies = models.CharField(max_length=500, blank=True, verbose_name='Технологии') + start_date = models.DateField(verbose_name='Дата начала') + end_date = models.DateField(null=True, blank=True, verbose_name='Дата окончания') + is_current = models.BooleanField(default=False, verbose_name='Текущее место работы') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + + class Meta: + verbose_name = 'Опыт работы' + verbose_name_plural = 'Опыт работы' + ordering = ['order', '-start_date'] + + def __str__(self): + return f"{self.title} — {self.company}" + + def get_responsibilities_list(self): + if not self.responsibilities: + return [] + return [r.strip() for r in self.responsibilities.split('\n') if r.strip()] + + def get_technologies_list(self): + if not self.technologies: + return [] + return [t.strip() for t in self.technologies.split(',') if t.strip()] + + @property + def duration(self): + end = self.end_date or __import__('datetime').date.today() + months = (end.year - self.start_date.year) * 12 + (end.month - self.start_date.month) + years = months // 12 + remaining_months = months % 12 + + parts = [] + if years: + parts.append(f"{years} {'год' if years == 1 else 'года' if 2 <= years <= 4 else 'лет'}") + if remaining_months: + parts.append( + f"{remaining_months} {'месяц' if remaining_months == 1 else 'месяца' if 2 <= remaining_months <= 4 else 'месяцев'}") + return ' '.join(parts) if parts else 'Менее месяца' + + +class Education(models.Model): + """Модель образования.""" + TYPE_CHOICES = [ + ('university', 'Высшее образование'), + ('course', 'Курс/Сертификат'), + ('school', 'Школа/Лицей'), + ] + + institution = models.CharField(max_length=300, verbose_name='Учебное заведение') + institution_short = models.CharField(max_length=100, blank=True, verbose_name='Сокращённое название') + degree = models.CharField(max_length=200, verbose_name='Специальность/Программа') + education_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='university', verbose_name='Тип') + description = models.TextField(blank=True, verbose_name='Описание') + achievements = models.TextField(blank=True, verbose_name='Достижения (каждое с новой строки)') + start_year = models.PositiveIntegerField(verbose_name='Год начала') + end_year = models.PositiveIntegerField(null=True, blank=True, verbose_name='Год окончания') + is_current = models.BooleanField(default=False, verbose_name='В процессе обучения') + certificate_number = models.CharField(max_length=100, blank=True, verbose_name='Номер сертификата') + certificate_url = models.URLField(blank=True, verbose_name='Ссылка на сертификат') + icon = models.CharField(max_length=50, default='fas fa-graduation-cap', verbose_name='Иконка') + order = models.PositiveIntegerField(default=0, verbose_name='Порядок') + + class Meta: + verbose_name = 'Образование' + verbose_name_plural = 'Образование' + ordering = ['order', '-start_year'] + + def __str__(self): + return f"{self.degree} — {self.institution_short or self.institution}" + + def get_achievements_list(self): + if not self.achievements: + return [] + return [a.strip() for a in self.achievements.split('\n') if a.strip()] + + +class Comment(models.Model): + """Модель комментариев к статьям.""" + article = models.ForeignKey( + Article, on_delete=models.CASCADE, related_name='comments', verbose_name='Статья' + ) + user = models.ForeignKey( + CustomUser, on_delete=models.CASCADE, related_name='comments', verbose_name='Пользователь' + ) + parent = models.ForeignKey( + 'self', on_delete=models.CASCADE, null=True, blank=True, + related_name='replies', verbose_name='Родительский комментарий' + ) + content = models.TextField(max_length=2000, verbose_name='Содержание') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') + updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') + is_approved = models.BooleanField(default=True, verbose_name='Одобрен') + + class Meta: + verbose_name = 'Комментарий' + verbose_name_plural = 'Комментарии' + ordering = ['created_at'] + + def __str__(self): + return f'{self.user.username}: {self.content[:50]}...' + + @property + def likes_count(self): + return self.comment_likes.filter(is_like=True).count() + + @property + def dislikes_count(self): + return self.comment_likes.filter(is_like=False).count() + + @property + def nesting_level(self): + level = 0 + parent = self.parent + while parent and level < 3: + level += 1 + parent = parent.parent + return level + + +class ArticleLike(models.Model): + """Модель лайков/дизлайков статей.""" + article = models.ForeignKey( + Article, on_delete=models.CASCADE, related_name='likes', verbose_name='Статья' + ) + user = models.ForeignKey( + CustomUser, on_delete=models.CASCADE, related_name='article_likes', verbose_name='Пользователь', + null=True, blank=True + ) + visitor_id = models.CharField(max_length=64, blank=True, db_index=True, verbose_name='ID посетителя') + ip_hash = models.CharField(max_length=64, blank=True, db_index=True, verbose_name='Хэш IP') + is_like = models.BooleanField(verbose_name='Лайк') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата') + + class Meta: + verbose_name = 'Оценка статьи' + verbose_name_plural = 'Оценки статей' + constraints = [ + models.UniqueConstraint(fields=['article', 'user'], condition=models.Q(user__isnull=False), + name='uniq_articlelike_user'), + models.UniqueConstraint(fields=['article', 'visitor_id'], condition=~models.Q(visitor_id=''), + name='uniq_articlelike_visitor'), + ] + + +class ArticleView(models.Model): + """Уникальный просмотр статьи (один на посетителя).""" + article = models.ForeignKey( + Article, on_delete=models.CASCADE, related_name='unique_views', verbose_name='Статья' + ) + visitor_id = models.CharField(max_length=64, verbose_name='ID посетителя') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата') + + class Meta: + verbose_name = 'Уникальный просмотр' + verbose_name_plural = 'Уникальные просмотры' + unique_together = ['article', 'visitor_id'] + + +class CommentLike(models.Model): + """Модель лайков/дизлайков комментариев.""" + comment = models.ForeignKey( + Comment, on_delete=models.CASCADE, related_name='comment_likes', verbose_name='Комментарий' + ) + user = models.ForeignKey( + CustomUser, on_delete=models.CASCADE, related_name='comment_likes', verbose_name='Пользователь', + null=True, blank=True + ) + visitor_id = models.CharField(max_length=64, blank=True, db_index=True, verbose_name='ID посетителя') + ip_hash = models.CharField(max_length=64, blank=True, db_index=True, verbose_name='Хэш IP') + is_like = models.BooleanField(verbose_name='Лайк') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата') + + class Meta: + verbose_name = 'Оценка комментария' + verbose_name_plural = 'Оценки комментариев' + constraints = [ + models.UniqueConstraint(fields=['comment', 'user'], condition=models.Q(user__isnull=False), + name='uniq_commentlike_user'), + models.UniqueConstraint(fields=['comment', 'visitor_id'], condition=~models.Q(visitor_id=''), + name='uniq_commentlike_visitor'), + ] + + +class ContactMessage(models.Model): + """Модель сообщений обратной связи.""" + name = models.CharField(max_length=100, verbose_name='Имя') + email = models.EmailField(verbose_name='Email') + subject = models.CharField(max_length=200, verbose_name='Тема') + message = models.TextField(verbose_name='Сообщение') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата') + is_read = models.BooleanField(default=False, verbose_name='Прочитано') + is_replied = models.BooleanField(default=False, verbose_name='Отвечено') + admin_notes = models.TextField(blank=True, verbose_name='Заметки администратора') + + class Meta: + verbose_name = 'Сообщение' + verbose_name_plural = 'Сообщения' + ordering = ['-created_at'] + + def __str__(self): + return f'{self.name}: {self.subject}' + + +class SiteSettings(models.Model): + """Настройки сайта (Singleton).""" + site_name = models.CharField(max_length=100, default='deev.space', verbose_name='Название сайта') + site_description = models.TextField(blank=True, verbose_name='Описание сайта') + owner_name = models.CharField(max_length=200, default='Деев Егор', verbose_name='Имя владельца') + owner_title = models.CharField(max_length=200, default='Backend Developer', verbose_name='Должность') + owner_bio = models.TextField(blank=True, verbose_name='О себе') + owner_photo = models.ImageField(upload_to='site/', blank=True, null=True, verbose_name='Фото владельца') + owner_email = models.EmailField(default='egor@deev.space', verbose_name='Email') + owner_phone = models.CharField(max_length=20, blank=True, verbose_name='Телефон') + owner_city = models.CharField(max_length=100, default='Москва', verbose_name='Город') + telegram_url = models.URLField(blank=True, verbose_name='Telegram') + github_url = models.URLField(blank=True, verbose_name='GitHub') + vk_url = models.URLField(blank=True, verbose_name='VKontakte') + linkedin_url = models.URLField(blank=True, verbose_name='LinkedIn') + resume_file = models.FileField(upload_to='site/', blank=True, null=True, verbose_name='Файл резюме') + yandex_metrika_id = models.CharField(max_length=20, blank=True, verbose_name='ID Яндекс.Метрики') + google_analytics_id = models.CharField(max_length=20, blank=True, verbose_name='ID Google Analytics') + open_to_work = models.BooleanField(default=True, verbose_name='Открыт к предложениям', + help_text='Показывать статус на главной') + work_format = models.CharField(max_length=100, blank=True, default='Удалённо или гибрид, фриланс', + verbose_name='Формат работы') + + class Meta: + verbose_name = 'Настройки сайта' + verbose_name_plural = 'Настройки сайта' + + def __str__(self): + return self.site_name + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + + @classmethod + def load(cls): + obj, created = cls.objects.get_or_create(pk=1) return obj \ No newline at end of file diff --git a/main/signals.py b/main/signals.py index 875486b..0831c0a 100644 --- a/main/signals.py +++ b/main/signals.py @@ -1,109 +1,136 @@ -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.conf import settings -import requests -import logging - -from .models import Article - -logger = logging.getLogger(__name__) - - -@receiver(post_save, sender=Article) -def publish_to_social_media(sender, instance, created, **kwargs): - """Автоматическая публикация новой статьи в социальные сети.""" - if not created: - return - - if not instance.is_published: - return - - if instance.is_achievement: - return - - # Telegram - if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHANNEL_ID: - try: - publish_to_telegram(instance) - logger.info(f'Статья "{instance.title}" опубликована в Telegram') - except Exception as e: - logger.error(f'Ошибка публикации в Telegram: {e}') - - # VK - if settings.VK_ACCESS_TOKEN and settings.VK_GROUP_ID: - try: - publish_to_vk(instance) - logger.info(f'Статья "{instance.title}" опубликована в VK') - except Exception as e: - logger.error(f'Ошибка публикации в VK: {e}') - - -def publish_to_telegram(article): - """Публикация статьи в Telegram-канал.""" - bot_token = settings.TELEGRAM_BOT_TOKEN - channel_id = settings.TELEGRAM_CHANNEL_ID - - article_url = f"https://deev.space{article.get_absolute_url()}" - - text = f"📝 *{escape_markdown(article.title)}*\n\n" - if article.excerpt: - excerpt = article.excerpt[:200] + '...' if len(article.excerpt) > 200 else article.excerpt - text += f"{escape_markdown(excerpt)}\n\n" - text += f"[Читать полностью]({article_url})" - - if article.img: - url = f"https://api.telegram.org/bot{bot_token}/sendPhoto" - data = { - 'chat_id': channel_id, - 'photo': f"https://deev.space{article.img.url}", - 'caption': text, - 'parse_mode': 'Markdown' - } - else: - url = f"https://api.telegram.org/bot{bot_token}/sendMessage" - data = { - 'chat_id': channel_id, - 'text': text, - 'parse_mode': 'Markdown', - 'disable_web_page_preview': False - } - - response = requests.post(url, data=data, timeout=10) - response.raise_for_status() - - -def publish_to_vk(article): - """Публикация статьи в группу VKontakte.""" - access_token = settings.VK_ACCESS_TOKEN - group_id = settings.VK_GROUP_ID - - article_url = f"https://deev.space{article.get_absolute_url()}" - - message = f"📝 {article.title}\n\n" - if article.excerpt: - excerpt = article.excerpt[:300] + '...' if len(article.excerpt) > 300 else article.excerpt - message += f"{excerpt}\n\n" - message += f"🔗 Читать: {article_url}" - - url = "https://api.vk.com/method/wall.post" - params = { - 'owner_id': f'-{group_id}', - 'message': message, - 'from_group': 1, - 'access_token': access_token, - 'v': '5.131' - } - - if article.img: - params['attachments'] = f"https://deev.space{article.img.url}" - - response = requests.post(url, params=params, timeout=10) - response.raise_for_status() - - -def escape_markdown(text): - """Экранирование специальных символов Markdown.""" - escape_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] - for char in escape_chars: - text = text.replace(char, f'\\{char}') - return text \ No newline at end of file +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.conf import settings +import requests +import logging + +from .models import Article + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=Article) +def publish_to_social_media(sender, instance, created, **kwargs): + """Автоматическая публикация новой статьи в социальные сети.""" + if not created: + return + + if not instance.is_published: + return + + if instance.is_achievement: + return + + ''' + # Telegram + if settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHANNEL_ID: + try: + publish_to_telegram(instance) + logger.info(f'Статья "{instance.title}" опубликована в Telegram') + except Exception as e: + logger.error(f'Ошибка публикации в Telegram: {e}') + + # VK + if settings.VK_ACCESS_TOKEN and settings.VK_GROUP_ID: + try: + publish_to_vk(instance) + logger.info(f'Статья "{instance.title}" опубликована в VK') + except Exception as e: + logger.error(f'Ошибка публикации в VK: {e}') + ''' + + try: + ping_search_engines(instance) + logger.info(f'Поисковые системы уведомлены о статье "{instance.title}"') + except Exception as e: + logger.error(f'Ошибка уведомления поисковых систем: {e}') + + +def publish_to_telegram(article): + """Публикация статьи в Telegram-канал.""" + bot_token = settings.TELEGRAM_BOT_TOKEN + channel_id = settings.TELEGRAM_CHANNEL_ID + + article_url = f"https://deev.space{article.get_absolute_url()}" + + text = f"📝 *{escape_markdown(article.title)}*\n\n" + if article.excerpt: + excerpt = article.excerpt[:200] + '...' if len(article.excerpt) > 200 else article.excerpt + text += f"{escape_markdown(excerpt)}\n\n" + text += f"[Читать полностью]({article_url})" + + if article.img: + url = f"https://api.telegram.org/bot{bot_token}/sendPhoto" + data = { + 'chat_id': channel_id, + 'photo': f"https://deev.space{article.img.url}", + 'caption': text, + 'parse_mode': 'Markdown' + } + else: + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + data = { + 'chat_id': channel_id, + 'text': text, + 'parse_mode': 'Markdown', + 'disable_web_page_preview': False + } + + response = requests.post(url, data=data, timeout=10) + response.raise_for_status() + + +def publish_to_vk(article): + """Публикация статьи в группу VKontakte.""" + access_token = settings.VK_ACCESS_TOKEN + group_id = settings.VK_GROUP_ID + + article_url = f"https://deev.space{article.get_absolute_url()}" + + message = f"📝 {article.title}\n\n" + if article.excerpt: + excerpt = article.excerpt[:300] + '...' if len(article.excerpt) > 300 else article.excerpt + message += f"{excerpt}\n\n" + message += f"🔗 Читать: {article_url}" + + url = "https://api.vk.com/method/wall.post" + params = { + 'owner_id': f'-{group_id}', + 'message': message, + 'from_group': 1, + 'access_token': access_token, + 'v': '5.131' + } + + if article.img: + params['attachments'] = f"https://deev.space{article.img.url}" + + response = requests.post(url, params=params, timeout=10) + response.raise_for_status() + + +def escape_markdown(text): + """Экранирование специальных символов Markdown.""" + escape_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] + for char in escape_chars: + text = text.replace(char, f'\\{char}') + return text + + +def ping_search_engines(article): + """Уведомление поисковых систем о новой статье.""" + article_url = f"https://deev.space{article.get_absolute_url()}" + + # Google + try: + google_ping_url = f"https://www.google.com/ping?sitemap=https://deev.space/sitemap.xml" + requests.get(google_ping_url, timeout=5) + except Exception as e: + logger.error(f'Ошибка ping Google: {e}') + + # Яндекс + try: + yandex_ping_url = f"https://webmaster.yandex.ru/ping?sitemap=https://deev.space/sitemap.xml" + requests.get(yandex_ping_url, timeout=5) + except Exception as e: + logger.error(f'Ошибка ping Яндекс: {e}') \ No newline at end of file diff --git a/main/sitemaps.py b/main/sitemaps.py index 19bbe0d..6b497ee 100644 --- a/main/sitemaps.py +++ b/main/sitemaps.py @@ -1,39 +1,39 @@ -from django.contrib.sitemaps import Sitemap -from django.urls import reverse -from .models import Article, Project - - -class ArticleSitemap(Sitemap): - changefreq = 'weekly' - priority = 0.8 - - def items(self): - return Article.objects.filter(is_published=True) - - def lastmod(self, obj): - return obj.updated_at - - def location(self, obj): - return obj.get_absolute_url() - - -class ProjectSitemap(Sitemap): - changefreq = 'monthly' - priority = 0.7 - - def items(self): - return Project.objects.filter(is_visible=True) - - def location(self, obj): - return f'/projects/#{obj.slug}' - - -class StaticViewSitemap(Sitemap): - priority = 0.5 - changefreq = 'monthly' - - def items(self): - return ['index', 'about', 'projects', 'blog', 'achievements', 'contacts'] - - def location(self, item): +from django.contrib.sitemaps import Sitemap +from django.urls import reverse +from .models import Article, Project + + +class ArticleSitemap(Sitemap): + changefreq = 'weekly' + priority = 0.8 + + def items(self): + return Article.objects.filter(is_published=True) + + def lastmod(self, obj): + return obj.updated_at + + def location(self, obj): + return obj.get_absolute_url() + + +class ProjectSitemap(Sitemap): + changefreq = 'monthly' + priority = 0.7 + + def items(self): + return Project.objects.filter(is_visible=True) + + def location(self, obj): + return f'/projects/#{obj.slug}' + + +class StaticViewSitemap(Sitemap): + priority = 0.5 + changefreq = 'monthly' + + def items(self): + return ['index', 'about', 'projects', 'blog', 'achievements', 'contacts'] + + def location(self, item): return reverse(item) \ No newline at end of file diff --git a/main/templatetags/custom_filters.py b/main/templatetags/custom_filters.py index 0ab981d..91b7938 100644 --- a/main/templatetags/custom_filters.py +++ b/main/templatetags/custom_filters.py @@ -1,141 +1,155 @@ -from django import template -from django.utils.safestring import mark_safe -import re, math - -register = template.Library() - - -@register.filter -def get_item(dictionary, key): - """Получение значения из словаря по ключу.""" - return dictionary.get(key) - - -@register.filter -def split(value, separator=','): - """Разделение строки на список.""" - if not value: - return [] - return [item.strip() for item in value.split(separator) if item.strip()] - - -@register.filter -def first_letter(value): - """Получение первой буквы строки.""" - if value: - return value[0].upper() - return '' - - -@register.filter -def tech_icon(tech_name): - """Возвращает CSS класс иконки для технологии.""" - icons = { - 'python': 'devicon-python-plain', - 'django': 'devicon-django-plain', - 'fastapi': 'devicon-fastapi-plain', - 'flask': 'devicon-flask-original', - 'postgresql': 'devicon-postgresql-plain', - 'sqlite': 'devicon-sqlite-plain', - 'mysql': 'devicon-mysql-plain', - 'mongodb': 'devicon-mongodb-plain', - 'redis': 'devicon-redis-plain', - 'docker': 'devicon-docker-plain', - 'kubernetes': 'devicon-kubernetes-plain', - 'nginx': 'devicon-nginx-original', - 'git': 'devicon-git-plain', - 'github': 'fab fa-github', - 'gitlab': 'devicon-gitlab-plain', - 'linux': 'devicon-linux-plain', - 'bash': 'devicon-bash-plain', - 'html': 'devicon-html5-plain', - 'html5': 'devicon-html5-plain', - 'css': 'devicon-css3-plain', - 'css3': 'devicon-css3-plain', - 'javascript': 'devicon-javascript-plain', - 'js': 'devicon-javascript-plain', - 'typescript': 'devicon-typescript-plain', - 'react': 'devicon-react-original', - 'vue': 'devicon-vuejs-plain', - 'nodejs': 'devicon-nodejs-plain', - 'bootstrap': 'devicon-bootstrap-plain', - 'tailwind': 'devicon-tailwindcss-plain', - 'figma': 'devicon-figma-plain', - 'c': 'devicon-c-plain', - 'c++': 'devicon-cplusplus-plain', - 'cpp': 'devicon-cplusplus-plain', - 'java': 'devicon-java-plain', - 'rust': 'devicon-rust-plain', - 'go': 'devicon-go-plain', - 'qt': 'devicon-qt-original', - 'pyqt': 'devicon-qt-original', - 'asyncio': 'fas fa-bolt', - 'aiogram': 'fab fa-telegram', - 'telegram': 'fab fa-telegram', - 'request': 'fas fa-globe', - 'requests': 'fas fa-globe', - 'pandas': 'devicon-pandas-original', - 'numpy': 'devicon-numpy-original', - 'gtts': 'fas fa-microphone', - 'psycopg2': 'devicon-postgresql-plain', - 'chroma': 'fas fa-database', - 'flowise': 'fas fa-robot', - 'winscp': 'fas fa-server', - } - - tech_lower = tech_name.lower().strip() - return icons.get(tech_lower, 'fas fa-code') - - -@register.filter -def status_class(status): - """Возвращает CSS класс для статуса проекта.""" - classes = { - 'completed': 'completed', - 'in_development': 'beta', - 'beta': 'beta', - } - return classes.get(status, 'beta') - - -@register.filter -def status_label(status): - """Возвращает текстовую метку для статуса.""" - labels = { - 'completed': 'Релиз', - 'in_development': 'В разработке', - 'beta': 'Бета', - } - return labels.get(status, status) - - -@register.simple_tag -def render_tech_badge(tech_name): - """Рендерит HTML для бейджа технологии.""" - icon_class = tech_icon(tech_name) - html = f' {tech_name}' - return mark_safe(html) - - -@register.filter -def linebreaks_list(value): - """Преобразует текст с переносами строк в список.""" - if not value: - return [] - return [line.strip() for line in value.split('\n') if line.strip()] - - -@register.filter -def reading_time(text, words_per_minute=200): - """ - Рассчитывает время чтения текста в минутах - """ - if not text: - return 0 - - # Подсчет слов (простой способ) - word_count = len(text.split()) - - # Расчет минут с округлением вверх - minutes = math.ceil(word_count / words_per_minute) - return max(1, minutes) # минимум 1 минута \ No newline at end of file +from django import template +from django.utils.safestring import mark_safe +import re, math + +register = template.Library() + + +@register.filter +def get_item(dictionary, key): + """Получение значения из словаря по ключу.""" + return dictionary.get(key) + + +@register.filter +def split(value, separator=','): + """Разделение строки на список.""" + if not value: + return [] + return [item.strip() for item in value.split(separator) if item.strip()] + + +@register.filter +def first_letter(value): + """Получение первой буквы строки.""" + if value: + return value[0].upper() + return '' + + +@register.filter +def tech_icon(tech_name): + """Возвращает CSS класс иконки для технологии.""" + icons = { + 'python': 'devicon-python-plain', + 'django': 'devicon-django-plain', + 'fastapi': 'devicon-fastapi-plain', + 'flask': 'devicon-flask-original', + 'postgresql': 'devicon-postgresql-plain', + 'sqlite': 'devicon-sqlite-plain', + 'mysql': 'devicon-mysql-plain', + 'mongodb': 'devicon-mongodb-plain', + 'redis': 'devicon-redis-plain', + 'docker': 'devicon-docker-plain', + 'kubernetes': 'devicon-kubernetes-plain', + 'nginx': 'devicon-nginx-original', + 'git': 'devicon-git-plain', + 'github': 'fab fa-github', + 'gitlab': 'devicon-gitlab-plain', + 'linux': 'devicon-linux-plain', + 'bash': 'devicon-bash-plain', + 'html': 'devicon-html5-plain', + 'html5': 'devicon-html5-plain', + 'css': 'devicon-css3-plain', + 'css3': 'devicon-css3-plain', + 'javascript': 'devicon-javascript-plain', + 'js': 'devicon-javascript-plain', + 'typescript': 'devicon-typescript-plain', + 'react': 'devicon-react-original', + 'vue': 'devicon-vuejs-plain', + 'nodejs': 'devicon-nodejs-plain', + 'bootstrap': 'devicon-bootstrap-plain', + 'tailwind': 'devicon-tailwindcss-plain', + 'figma': 'devicon-figma-plain', + 'c': 'devicon-c-plain', + 'c++': 'devicon-cplusplus-plain', + 'cpp': 'devicon-cplusplus-plain', + 'java': 'devicon-java-plain', + 'rust': 'devicon-rust-plain', + 'go': 'devicon-go-plain', + 'qt': 'devicon-qt-original', + 'pyqt': 'devicon-qt-original', + 'asyncio': 'fas fa-bolt', + 'aiogram': 'fab fa-telegram', + 'telegram': 'fab fa-telegram', + 'request': 'fas fa-globe', + 'requests': 'fas fa-globe', + 'pandas': 'devicon-pandas-original', + 'numpy': 'devicon-numpy-original', + 'gtts': 'fas fa-microphone', + 'psycopg2': 'devicon-postgresql-plain', + 'chroma': 'fas fa-database', + 'flowise': 'fas fa-robot', + 'winscp': 'fas fa-server', + } + + tech_lower = tech_name.lower().strip() + return icons.get(tech_lower, 'fas fa-code') + + +@register.filter +def status_class(status): + """Возвращает CSS класс для статуса проекта.""" + classes = { + 'completed': 'completed', + 'in_development': 'beta', + 'beta': 'beta', + } + return classes.get(status, 'beta') + + +@register.filter +def status_label(status): + """Возвращает текстовую метку для статуса.""" + labels = { + 'completed': 'Релиз', + 'in_development': 'В разработке', + 'beta': 'Бета', + } + return labels.get(status, status) + + +@register.simple_tag +def render_tech_badge(tech_name): + """Рендерит HTML для бейджа технологии.""" + icon_class = tech_icon(tech_name) + html = f' {tech_name}' + return mark_safe(html) + + +@register.filter +def linebreaks_list(value): + """Преобразует текст с переносами строк в список.""" + if not value: + return [] + return [line.strip() for line in value.split('\n') if line.strip()] + + +@register.filter +def reading_time(text, words_per_minute=200): + """ + Рассчитывает время чтения текста в минутах + """ + if not text: + return 0 + + # Подсчет слов (простой способ) + word_count = len(text.split()) + + # Расчет минут с округлением вверх + minutes = math.ceil(word_count / words_per_minute) + return max(1, minutes) # минимум 1 минута + +@register.filter +def ru_plural(value, forms): + """Склонение по числу: {{ n|ru_plural:"проект,проекта,проектов" }}.""" + one, few, many = [f.strip() for f in forms.split(',')] + n = abs(int(value)) % 100 + if 11 <= n <= 14: + return many + n %= 10 + if n == 1: + return one + if 2 <= n <= 4: + return few + return many diff --git a/main/tests.py b/main/tests.py index de8bdc0..7ce503c 100644 --- a/main/tests.py +++ b/main/tests.py @@ -1,3 +1,3 @@ -from django.test import TestCase - -# Create your tests here. +from django.test import TestCase + +# Create your tests here. diff --git a/main/urls.py b/main/urls.py index dc3cba7..d216d67 100644 --- a/main/urls.py +++ b/main/urls.py @@ -1,26 +1,26 @@ -from django.urls import path -from . import views - -urlpatterns = [ - # Основные страницы - path('', views.IndexView.as_view(), name='index'), - path('about/', views.AboutView.as_view(), name='about'), - path('projects/', views.ProjectsView.as_view(), name='projects'), - path('achievements/', views.AchievementsView.as_view(), name='achievements'), - path('contacts/', views.ContactsView.as_view(), name='contacts'), - - # Блог - path('blog/', views.BlogView.as_view(), name='blog'), - path('blog/category//', views.BlogView.as_view(), name='blog_category'), - path('blog//', views.ArticleDetailView.as_view(), name='article_detail'), - - # Аутентификация - path('register/', views.register_view, name='register'), - path('login/', views.login_view, name='login'), - path('logout/', views.logout_view, name='logout'), - - # API - path('api/article//comment/', views.add_comment, name='add_comment'), - path('api/article//like/', views.toggle_article_like, name='toggle_article_like'), - path('api/comment//like/', views.toggle_comment_like, name='toggle_comment_like'), +from django.urls import path +from . import views + +urlpatterns = [ + # Основные страницы + path('', views.IndexView.as_view(), name='index'), + path('about/', views.AboutView.as_view(), name='about'), + path('projects/', views.ProjectsView.as_view(), name='projects'), + path('achievements/', views.AchievementsView.as_view(), name='achievements'), + path('contacts/', views.ContactsView.as_view(), name='contacts'), + + # Блог + path('blog/', views.BlogView.as_view(), name='blog'), + path('blog/category//', views.BlogView.as_view(), name='blog_category'), + path('blog//', views.ArticleDetailView.as_view(), name='article_detail'), + + # Аутентификация + path('register/', views.register_view, name='register'), + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), + + # API + path('api/article//comment/', views.add_comment, name='add_comment'), + path('api/article//like/', views.toggle_article_like, name='toggle_article_like'), + path('api/comment//like/', views.toggle_comment_like, name='toggle_comment_like'), ] \ No newline at end of file diff --git a/main/views.py b/main/views.py index 96e225a..40efcf5 100644 --- a/main/views.py +++ b/main/views.py @@ -1,542 +1,598 @@ -from django.shortcuts import render, get_object_or_404, redirect -from django.http import JsonResponse -from django.views.generic import ListView, DetailView, TemplateView -from django.contrib.auth import login, authenticate, logout -from django.contrib.auth.decorators import login_required -from django.contrib import messages -from django.core.paginator import Paginator -from django.core.mail import send_mail -from django.conf import settings -from django.db.models import Q, Count -from django.views.decorators.http import require_POST, require_GET -from django.utils import timezone -import json -import logging - -from .models import ( - Article, Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike, - ContactMessage, Experience, Education, Category, SiteSettings -) -from .forms import RegisterForm, LoginForm, CommentForm, ContactForm - -logger = logging.getLogger(__name__) - - -def get_site_settings(): - """Получение настроек сайта.""" - return SiteSettings.load() - - -class IndexView(TemplateView): - """Главная страница.""" - template_name = 'index.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - site_settings = get_site_settings() - - context['site_settings'] = site_settings - context['skills'] = Skill.objects.all() - context['skills_by_category'] = self._get_skills_by_category() - context['recent_articles'] = Article.objects.filter( - is_published=True, is_achievement=False - ).select_related('category')[:3] - - # Только проекты с галочкой "Показывать на главной", сортировка по homepage_order - context['featured_projects'] = Project.objects.filter( - is_visible=True, - show_on_homepage=True - ).order_by('homepage_order', 'order', '-date')[:6] - - context['experiences'] = Experience.objects.all()[:2] - - context['page_title'] = f'{site_settings.owner_name} — {site_settings.owner_title}' - context[ - 'page_description'] = site_settings.site_description or f'Персональный сайт {site_settings.owner_title.lower()} {site_settings.owner_name}' - - return context - - def _get_skills_by_category(self): - skills = Skill.objects.all() - categories = {} - for skill in skills: - cat_display = skill.get_category_display() - if cat_display not in categories: - categories[cat_display] = [] - categories[cat_display].append(skill) - return categories - - -class AboutView(TemplateView): - """Страница 'Обо мне'.""" - template_name = 'about.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - site_settings = get_site_settings() - - context['site_settings'] = site_settings - context['experiences'] = Experience.objects.all() - context['educations'] = Education.objects.all() - context['skills'] = Skill.objects.all() - context['skills_by_category'] = self._get_skills_by_category() - - context['page_title'] = f'Обо мне — {site_settings.owner_name}' - context[ - 'page_description'] = f'Профессиональный путь, образование и навыки {site_settings.owner_title.lower()} {site_settings.owner_name}' - - return context - - def _get_skills_by_category(self): - skills = Skill.objects.all() - categories = {} - for skill in skills: - cat_display = skill.get_category_display() - if cat_display not in categories: - categories[cat_display] = [] - categories[cat_display].append(skill) - return categories - - -class ProjectsView(ListView): - """Страница проектов.""" - model = Project - template_name = 'projects.html' - context_object_name = 'projects' - - def get_queryset(self): - queryset = Project.objects.filter(is_visible=True).select_related('status') - - lang_filter = self.request.GET.get('lang') - tech_filter = self.request.GET.get('tech') - status_filter = self.request.GET.get('status') - - if lang_filter: - queryset = queryset.filter(programming_languages__icontains=lang_filter) - - if tech_filter: - queryset = queryset.filter(technologies__icontains=tech_filter) - - if status_filter: - if status_filter == 'completed': - # Только проекты со статусом, помеченным как is_release - queryset = queryset.filter(status__is_release=True) - elif status_filter == 'in_development': - # Все остальные (не релиз) - queryset = queryset.filter( - Q(status__is_release=False) | Q(status__isnull=True) - ) - - return queryset.order_by('order', '-date') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - site_settings = get_site_settings() - - base_queryset = Project.objects.filter(is_visible=True).select_related('status') - - lang_filter = self.request.GET.get('lang') - tech_filter = self.request.GET.get('tech') - status_filter = self.request.GET.get('status') - - if lang_filter: - base_queryset = base_queryset.filter(programming_languages__icontains=lang_filter) - if tech_filter: - base_queryset = base_queryset.filter(technologies__icontains=tech_filter) - - # Разделение по статусам - if status_filter == 'completed': - context['completed_projects'] = base_queryset.filter(status__is_release=True).order_by('order', '-date') - context['dev_projects'] = Project.objects.none() - elif status_filter == 'in_development': - context['completed_projects'] = Project.objects.none() - context['dev_projects'] = base_queryset.filter( - Q(status__is_release=False) | Q(status__isnull=True) - ).order_by('order', '-date') - else: - context['completed_projects'] = base_queryset.filter(status__is_release=True).order_by('order', '-date') - context['dev_projects'] = base_queryset.filter( - Q(status__is_release=False) | Q(status__isnull=True) - ).order_by('order', '-date') - - # Собираем все языки программирования для фильтра - all_languages = set() - all_techs = set() - for project in Project.objects.filter(is_visible=True): - all_languages.update(project.get_languages_list()) - all_techs.update(project.get_technologies_list()) - - context['all_languages'] = sorted(all_languages) - context['all_technologies'] = sorted(all_techs) - - context['current_lang'] = self.request.GET.get('lang', '') - context['current_tech'] = self.request.GET.get('tech', '') - context['current_status'] = self.request.GET.get('status', '') - - context['page_title'] = f'Проекты — {site_settings.owner_name}' - context[ - 'page_description'] = 'Портфолио проектов: Telegram-боты, веб-приложения, базы данных и инструменты разработки' - - return context - - -class BlogView(ListView): - """Страница блога.""" - model = Article - template_name = 'blog/blog.html' - context_object_name = 'articles' - paginate_by = 9 - - def get_queryset(self): - queryset = Article.objects.filter( - is_published=True, is_achievement=False - ).select_related('category') - - category_slug = self.kwargs.get('category_slug') - if category_slug: - queryset = queryset.filter(category__slug=category_slug) - - search_query = self.request.GET.get('q') - if search_query: - queryset = queryset.filter( - Q(title__icontains=search_query) | - Q(excerpt__icontains=search_query) | - Q(post__icontains=search_query) - ) - - return queryset.order_by('-date') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - site_settings = get_site_settings() - - context['categories'] = Category.objects.annotate( - count=Count('articles', filter=Q(articles__is_published=True, articles__is_achievement=False)) - ).filter(count__gt=0) - - category_slug = self.kwargs.get('category_slug') - context['current_category'] = None - if category_slug: - context['current_category'] = get_object_or_404(Category, slug=category_slug) - - context['search_query'] = self.request.GET.get('q', '') - context['total_articles'] = Article.objects.filter(is_published=True, is_achievement=False).count() - - context['page_title'] = f'Блог — {site_settings.owner_name}' - context['page_description'] = 'Технические статьи, гайды и размышления о разработке' - - return context - - -class ArticleDetailView(DetailView): - """Страница отдельной статьи.""" - model = Article - template_name = 'blog/article.html' - context_object_name = 'article' - slug_url_kwarg = 'slug' - - def get_queryset(self): - return Article.objects.filter(is_published=True).select_related('category') - - def get_object(self, queryset=None): - obj = super().get_object(queryset) - obj.views += 1 - obj.save(update_fields=['views']) - return obj - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - article = self.object - - # Комментарии только если включены - if article.comments_enabled: - context['comments'] = article.comments.filter( - is_approved=True, parent=None - ).select_related('user').prefetch_related( - 'replies__user', 'replies__replies__user' - ).order_by('-created_at') - else: - context['comments'] = [] - - context['comment_form'] = CommentForm() - - context['user_like'] = None - if self.request.user.is_authenticated: - like = ArticleLike.objects.filter( - article=article, user=self.request.user - ).first() - context['user_like'] = like - - context['related_articles'] = Article.objects.filter( - is_published=True, is_achievement=False, category=article.category - ).exclude(pk=article.pk)[:3] if article.category else Article.objects.none() - - # Галерея изображений - gallery_images = article.gallery_images.all() - if article.exclude_inline_from_blocks: - gallery_images = gallery_images.filter(is_inline=False) - context['gallery_images'] = gallery_images - - # Прикреплённые файлы - attached_files = article.attached_files.all() - if article.exclude_inline_from_blocks: - attached_files = attached_files.filter(is_inline=False) - context['attached_files'] = attached_files - - # Прикреплённые ссылки - attached_links = article.attached_links.all() - if article.exclude_inline_from_blocks: - attached_links = attached_links.filter(is_inline=False) - context['attached_links'] = attached_links - - context['page_title'] = f'{article.title} — Блог' - context['page_description'] = article.excerpt or article.post[:160] - context['og_image'] = article.img.url if article.img else None - - return context - - -class AchievementsView(ListView): - """Страница достижений.""" - model = Article - template_name = 'achievements.html' - context_object_name = 'achievements' - - def get_queryset(self): - return Article.objects.filter( - is_published=True, is_achievement=True - ).order_by('-achievement_date', '-date') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - site_settings = get_site_settings() - - context['page_title'] = f'Достижения — {site_settings.owner_name}' - context['page_description'] = 'Награды, сертификаты и профессиональные достижения' - - return context - - -class ContactsView(TemplateView): - """Страница контактов.""" - template_name = 'contacts.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - site_settings = get_site_settings() - - context['site_settings'] = site_settings - context['contact_form'] = ContactForm() - - context['smartcaptcha_client_key'] = settings.SMARTCAPTCHA_CLIENT_KEY - - context['page_title'] = f'Контакты — {site_settings.owner_name}' - context['page_description'] = 'Свяжитесь со мной: Telegram, email, социальные сети и форма обратной связи' - - return context - - def post(self, request, *args, **kwargs): - form = ContactForm(request.POST) - if form.is_valid(): - message = form.save() - - try: - send_mail( - subject=f'[deev.space] Новое сообщение: {message.subject}', - message=f'От: {message.name} ({message.email})\n\nТема: {message.subject}\n\n{message.message}', - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[settings.CONTACT_EMAIL], - fail_silently=True, - ) - except Exception as e: - logger.error(f'Ошибка отправки email: {e}') - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({'success': True, 'message': 'Сообщение успешно отправлено!'}) - - messages.success(request, 'Сообщение успешно отправлено!') - return redirect('contacts') - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return JsonResponse({'success': False, 'errors': form.errors}, status=400) - - context = self.get_context_data() - context['contact_form'] = form - return render(request, self.template_name, context) - - -def register_view(request): - """Регистрация пользователя.""" - if request.user.is_authenticated: - return redirect('index') - - if request.method == 'POST': - form = RegisterForm(request.POST) - if form.is_valid(): - user = form.save() - login(request, user) - messages.success(request, f'Добро пожаловать, {user.username}!') - - next_url = request.GET.get('next', 'index') - return redirect(next_url) - else: - form = RegisterForm() - - return render(request, 'auth/register.html', { - 'form': form, - 'page_title': 'Регистрация — deev.space', - 'smartcaptcha_client_key': settings.SMARTCAPTCHA_CLIENT_KEY - }) - - -def login_view(request): - """Авторизация пользователя.""" - if request.user.is_authenticated: - return redirect('index') - - if request.method == 'POST': - form = LoginForm(request, data=request.POST) - if form.is_valid(): - user = form.get_user() - login(request, user) - messages.success(request, f'С возвращением, {user.username}!') - - next_url = request.GET.get('next', 'index') - return redirect(next_url) - else: - form = LoginForm() - - return render(request, 'auth/login.html', { - 'form': form, - 'page_title': 'Вход — deev.space' - }) - - -def logout_view(request): - """Выход из системы.""" - logout(request) - messages.info(request, 'Вы вышли из системы') - return redirect('index') - - -@login_required -@require_POST -def add_comment(request, article_id): - """Добавление комментария к статье.""" - article = get_object_or_404(Article, id=article_id, is_published=True) - - # Проверяем, включены ли комментарии - if not article.comments_enabled: - return JsonResponse({'success': False, 'error': 'Комментарии отключены для этой статьи'}, status=403) - - form = CommentForm(request.POST) - - if form.is_valid(): - comment = form.save(commit=False) - comment.article = article - comment.user = request.user - - parent_id = request.POST.get('parent_id') - if parent_id: - parent = get_object_or_404(Comment, id=parent_id, article=article) - if parent.nesting_level < 3: - comment.parent = parent - - comment.save() - - return JsonResponse({ - 'success': True, - 'comment': { - 'id': comment.id, - 'user': comment.user.username, - 'avatar_letter': comment.user.get_avatar_letter(), - 'content': comment.content, - 'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'), - 'likes': 0, - 'dislikes': 0, - } - }) - - return JsonResponse({'success': False, 'errors': form.errors}, status=400) - - -@login_required -@require_POST -def toggle_article_like(request, article_id): - """Лайк/дизлайк статьи.""" - article = get_object_or_404(Article, id=article_id, is_published=True) - - try: - data = json.loads(request.body) - is_like = data.get('is_like', True) - except json.JSONDecodeError: - return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) - - like, created = ArticleLike.objects.get_or_create( - article=article, user=request.user, - defaults={'is_like': is_like} - ) - - user_vote = None - if not created: - if like.is_like == is_like: - like.delete() - else: - like.is_like = is_like - like.save() - user_vote = is_like - else: - user_vote = is_like - - return JsonResponse({ - 'success': True, - 'likes': article.likes_count, - 'dislikes': article.dislikes_count, - 'user_vote': user_vote - }) - - -@login_required -@require_POST -def toggle_comment_like(request, comment_id): - """Лайк/дизлайк комментария.""" - comment = get_object_or_404(Comment, id=comment_id, is_approved=True) - - try: - data = json.loads(request.body) - is_like = data.get('is_like', True) - except json.JSONDecodeError: - return JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) - - like, created = CommentLike.objects.get_or_create( - comment=comment, user=request.user, - defaults={'is_like': is_like} - ) - - user_vote = None - if not created: - if like.is_like == is_like: - like.delete() - else: - like.is_like = is_like - like.save() - user_vote = is_like - else: - user_vote = is_like - - return JsonResponse({ - 'success': True, - 'likes': comment.likes_count, - 'dislikes': comment.dislikes_count, - 'user_vote': user_vote - }) - - -def handler404(request, exception): - """Обработчик ошибки 404.""" - return render(request, 'errors/404.html', status=404) - - -def handler500(request): - """Обработчик ошибки 500.""" +from django.shortcuts import render, get_object_or_404, redirect +from django.http import JsonResponse +from django.views.generic import ListView, DetailView, TemplateView +from django.contrib.auth import login, authenticate, logout +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.core.paginator import Paginator +from django.core.mail import send_mail +from django.conf import settings +from django.db.models import Q, Count +from django.views.decorators.http import require_POST, require_GET +from django.utils import timezone +from django.utils.html import escape +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie +from django.db.models import Sum +from datetime import timedelta +from .middleware import client_ip_hash, is_bot +import json +import logging +import requests + +from .models import ( + Article, Project, ProjectStatus, Skill, Comment, ArticleLike, CommentLike, ArticleView, + ContactMessage, Experience, Education, Category, SiteSettings +) +from .forms import RegisterForm, LoginForm, CommentForm, ContactForm + +logger = logging.getLogger(__name__) + + +def get_site_settings(): + """Получение настроек сайта.""" + return SiteSettings.load() + + +def notify_alertbot_contact_message(message): + """Уведомление владельца в Telegram (через AlertBot) о новом сообщении с формы обратной связи. + + Не должно ронять отправку формы — все ошибки логируются на уровне вызывающего кода. + """ + bot_token = settings.ALERTBOT_TOKEN + chat_id = settings.ALERTBOT_CHAT_ID + + if not bot_token or not chat_id: + logger.warning('ALERTBOT_TOKEN/ALERTBOT_CHAT_ID не настроены — уведомление о сообщении с формы не отправлено') + return + + text = ( + f'Новое сообщение с сайта deev.space\n\n' + f'Имя: {escape(message.name)}\n' + f'Email: {escape(message.email)}\n' + f'Тема: {escape(message.subject)}\n\n' + f'{escape(message.message)}' + ) + + url = f'https://api.telegram.org/bot{bot_token}/sendMessage' + response = requests.post(url, data={ + 'chat_id': chat_id, + 'text': text, + 'parse_mode': 'HTML', + }, timeout=10) + response.raise_for_status() + + + +def get_skills_by_category(): + """Навыки по категориям в порядке Skill.CATEGORY_CHOICES.""" + rank = {key: i for i, (key, _) in enumerate(Skill.CATEGORY_CHOICES)} + skills = sorted(Skill.objects.all(), key=lambda s: (rank.get(s.category, len(rank)), s.order, s.name)) + categories = {} + for skill in skills: + categories.setdefault(skill.get_category_display(), []).append(skill) + return categories + + +class IndexView(TemplateView): + """Главная страница.""" + template_name = 'index.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + site_settings = get_site_settings() + + context['site_settings'] = site_settings + context['skills'] = Skill.objects.all() + context['skills_by_category'] = self._get_skills_by_category() + context['recent_articles'] = Article.objects.filter( + is_published=True, is_achievement=False + ).select_related('category')[:3] + + # Только проекты с галочкой "Показывать на главной", сортировка по homepage_order + context['featured_projects'] = Project.objects.filter( + is_visible=True, + show_on_homepage=True + ).order_by('homepage_order', 'order', '-date')[:6] + context['more_projects_count'] = max( + Project.objects.filter(is_visible=True).count() - len(context['featured_projects']), 0 + ) + + context['experiences'] = Experience.objects.all()[:2] + + context['page_title'] = f'{site_settings.owner_name} — {site_settings.owner_title}' + context[ + 'page_description'] = site_settings.site_description or f'Персональный сайт {site_settings.owner_title} {site_settings.owner_name}' + + return context + + def _get_skills_by_category(self): + return get_skills_by_category() + + +class AboutView(TemplateView): + """Страница 'Обо мне'.""" + template_name = 'about.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + site_settings = get_site_settings() + + context['site_settings'] = site_settings + context['experiences'] = Experience.objects.all() + context['educations'] = Education.objects.all() + context['skills'] = Skill.objects.all() + context['skills_by_category'] = self._get_skills_by_category() + context['projects_count'] = Project.objects.filter(is_visible=True).count() + context['skills_count_rounded'] = Skill.objects.count() // 10 * 10 + blog_views = Article.objects.filter( + is_published=True, is_achievement=False + ).aggregate(total=Sum('views'))['total'] or 0 + context['blog_views_display'] = ( + f'{blog_views // 100 / 10:g}k+' if blog_views >= 1000 else str(blog_views) + ) + + context['page_title'] = f'Обо мне — {site_settings.owner_name}' + context[ + 'page_description'] = f'Профессиональный путь, образование и навыки {site_settings.owner_title} {site_settings.owner_name}' + + return context + + def _get_skills_by_category(self): + return get_skills_by_category() + + +class ProjectsView(ListView): + """Страница проектов.""" + model = Project + template_name = 'projects.html' + context_object_name = 'projects' + + def get_queryset(self): + queryset = Project.objects.filter(is_visible=True).select_related('status') + + lang_filter = self.request.GET.get('lang') + tech_filter = self.request.GET.get('tech') + status_filter = self.request.GET.get('status') + + if lang_filter: + queryset = queryset.filter(programming_languages__icontains=lang_filter) + + if tech_filter: + queryset = queryset.filter(technologies__icontains=tech_filter) + + if status_filter: + if status_filter == 'completed': + # Только проекты со статусом, помеченным как is_release + queryset = queryset.filter(status__is_release=True) + elif status_filter == 'in_development': + # Все остальные (не релиз) + queryset = queryset.filter( + Q(status__is_release=False) | Q(status__isnull=True) + ) + + return queryset.order_by('order', '-date') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + site_settings = get_site_settings() + + base_queryset = Project.objects.filter(is_visible=True).select_related('status') + + lang_filter = self.request.GET.get('lang') + tech_filter = self.request.GET.get('tech') + status_filter = self.request.GET.get('status') + + if lang_filter: + base_queryset = base_queryset.filter(programming_languages__icontains=lang_filter) + if tech_filter: + base_queryset = base_queryset.filter(technologies__icontains=tech_filter) + + # Разделение по статусам + if status_filter == 'completed': + context['completed_projects'] = base_queryset.filter(status__is_release=True).order_by('order', '-date') + context['dev_projects'] = Project.objects.none() + elif status_filter == 'in_development': + context['completed_projects'] = Project.objects.none() + context['dev_projects'] = base_queryset.filter( + Q(status__is_release=False) | Q(status__isnull=True) + ).order_by('order', '-date') + else: + context['completed_projects'] = base_queryset.filter(status__is_release=True).order_by('order', '-date') + context['dev_projects'] = base_queryset.filter( + Q(status__is_release=False) | Q(status__isnull=True) + ).order_by('order', '-date') + + # Собираем все языки программирования для фильтра + all_languages = set() + all_techs = set() + for project in Project.objects.filter(is_visible=True): + all_languages.update(project.get_languages_list()) + all_techs.update(project.get_technologies_list()) + + context['all_languages'] = sorted(all_languages) + context['all_technologies'] = sorted(all_techs) + + context['current_lang'] = self.request.GET.get('lang', '') + context['current_tech'] = self.request.GET.get('tech', '') + context['current_status'] = self.request.GET.get('status', '') + + context['page_title'] = f'Проекты — {site_settings.owner_name}' + context[ + 'page_description'] = 'Портфолио проектов: Telegram-боты, веб-приложения, базы данных и инструменты разработки' + + return context + + +class BlogView(ListView): + """Страница блога.""" + model = Article + template_name = 'blog/blog.html' + context_object_name = 'articles' + paginate_by = 9 + + def get_queryset(self): + queryset = Article.objects.filter( + is_published=True, is_achievement=False + ).select_related('category') + + category_slug = self.kwargs.get('category_slug') + if category_slug: + queryset = queryset.filter(category__slug=category_slug) + + search_query = self.request.GET.get('q') + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) | + Q(excerpt__icontains=search_query) | + Q(post__icontains=search_query) + ) + + return queryset.order_by('-date') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + site_settings = get_site_settings() + + context['categories'] = Category.objects.annotate( + count=Count('articles', filter=Q(articles__is_published=True, articles__is_achievement=False)) + ).filter(count__gt=0) + + category_slug = self.kwargs.get('category_slug') + context['current_category'] = None + if category_slug: + context['current_category'] = get_object_or_404(Category, slug=category_slug) + + context['search_query'] = self.request.GET.get('q', '') + context['total_articles'] = Article.objects.filter(is_published=True, is_achievement=False).count() + + context['page_title'] = f'Блог — {site_settings.owner_name}' + context['page_description'] = 'Технические статьи, гайды и размышления о разработке' + + return context + + +@method_decorator(ensure_csrf_cookie, name='dispatch') +class ArticleDetailView(DetailView): + """Страница отдельной статьи.""" + model = Article + template_name = 'blog/article.html' + context_object_name = 'article' + slug_url_kwarg = 'slug' + + def get_queryset(self): + return Article.objects.filter(is_published=True).select_related('category') + + def get_object(self, queryset=None): + obj = super().get_object(queryset) + obj.views += 1 + obj.save(update_fields=['views']) + visitor_id = getattr(self.request, 'visitor_id', '') + if visitor_id and not is_bot(self.request): + ArticleView.objects.get_or_create(article=obj, visitor_id=visitor_id) + return obj + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + article = self.object + + # Комментарии только если включены + if article.comments_enabled: + context['comments'] = article.comments.filter( + is_approved=True, parent=None + ).select_related('user').prefetch_related( + 'replies__user', 'replies__replies__user' + ).order_by('-created_at') + else: + context['comments'] = [] + + context['comment_form'] = CommentForm() + + context['user_like'] = None + if self.request.user.is_authenticated: + like = ArticleLike.objects.filter( + article=article, user=self.request.user + ).first() + context['user_like'] = like + elif getattr(self.request, 'visitor_id', ''): + context['user_like'] = ArticleLike.objects.filter( + article=article, user__isnull=True, visitor_id=self.request.visitor_id + ).first() + + context['related_articles'] = Article.objects.filter( + is_published=True, is_achievement=False, category=article.category + ).exclude(pk=article.pk)[:3] if article.category else Article.objects.none() + + # Галерея изображений + gallery_images = article.gallery_images.all() + if article.exclude_inline_from_blocks: + gallery_images = gallery_images.filter(is_inline=False) + context['gallery_images'] = gallery_images + + # Прикреплённые файлы + attached_files = article.attached_files.all() + if article.exclude_inline_from_blocks: + attached_files = attached_files.filter(is_inline=False) + context['attached_files'] = attached_files + + # Прикреплённые ссылки + attached_links = article.attached_links.all() + if article.exclude_inline_from_blocks: + attached_links = attached_links.filter(is_inline=False) + context['attached_links'] = attached_links + + context['page_title'] = f'{article.title} — Блог' + context['page_description'] = article.excerpt or article.post[:160] + context['og_image'] = article.img.url if article.img else None + + return context + + +class AchievementsView(ListView): + """Страница достижений.""" + model = Article + template_name = 'achievements.html' + context_object_name = 'achievements' + + def get_queryset(self): + return Article.objects.filter( + is_published=True, is_achievement=True + ).order_by('-achievement_date', '-date') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + site_settings = get_site_settings() + + context['page_title'] = f'Достижения — {site_settings.owner_name}' + context['page_description'] = 'Награды, сертификаты и профессиональные достижения' + + return context + + +class ContactsView(TemplateView): + """Страница контактов.""" + template_name = 'contacts.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + site_settings = get_site_settings() + + context['site_settings'] = site_settings + context['contact_form'] = ContactForm() + + context['smartcaptcha_client_key'] = settings.SMARTCAPTCHA_CLIENT_KEY + + context['page_title'] = f'Контакты — {site_settings.owner_name}' + context['page_description'] = 'Свяжитесь со мной: Telegram, email, социальные сети и форма обратной связи' + + return context + + def post(self, request, *args, **kwargs): + form = ContactForm(request.POST) + if form.is_valid(): + message = form.save() + + try: + send_mail( + subject=f'[deev.space] Новое сообщение: {message.subject}', + message=f'От: {message.name} ({message.email})\n\nТема: {message.subject}\n\n{message.message}', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[settings.CONTACT_EMAIL], + fail_silently=True, + ) + except Exception as e: + logger.error(f'Ошибка отправки email: {e}') + + try: + notify_alertbot_contact_message(message) + except Exception as e: + logger.error(f'Ошибка отправки уведомления в AlertBot: {e}') + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': True, 'message': 'Сообщение успешно отправлено!'}) + + messages.success(request, 'Сообщение успешно отправлено!') + return redirect('contacts') + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({'success': False, 'errors': form.errors}, status=400) + + context = self.get_context_data() + context['contact_form'] = form + return render(request, self.template_name, context) + + +def register_view(request): + """Регистрация пользователя.""" + if request.user.is_authenticated: + return redirect('index') + + if request.method == 'POST': + form = RegisterForm(request.POST) + if form.is_valid(): + user = form.save() + login(request, user) + messages.success(request, f'Добро пожаловать, {user.username}!') + + next_url = request.GET.get('next', 'index') + return redirect(next_url) + else: + form = RegisterForm() + + return render(request, 'auth/register.html', { + 'form': form, + 'page_title': 'Регистрация — deev.space', + 'smartcaptcha_client_key': settings.SMARTCAPTCHA_CLIENT_KEY + }) + + +def login_view(request): + """Авторизация пользователя.""" + if request.user.is_authenticated: + return redirect('index') + + if request.method == 'POST': + form = LoginForm(request, data=request.POST) + if form.is_valid(): + user = form.get_user() + login(request, user) + messages.success(request, f'С возвращением, {user.username}!') + + next_url = request.GET.get('next', 'index') + return redirect(next_url) + else: + form = LoginForm() + + return render(request, 'auth/login.html', { + 'form': form, + 'page_title': 'Вход — deev.space' + }) + + +def logout_view(request): + """Выход из системы.""" + logout(request) + messages.info(request, 'Вы вышли из системы') + return redirect('index') + + +@login_required +@require_POST +def add_comment(request, article_id): + """Добавление комментария к статье.""" + article = get_object_or_404(Article, id=article_id, is_published=True) + + # Проверяем, включены ли комментарии + if not article.comments_enabled: + return JsonResponse({'success': False, 'error': 'Комментарии отключены для этой статьи'}, status=403) + + form = CommentForm(request.POST) + + if form.is_valid(): + comment = form.save(commit=False) + comment.article = article + comment.user = request.user + + parent_id = request.POST.get('parent_id') + if parent_id: + parent = get_object_or_404(Comment, id=parent_id, article=article) + if parent.nesting_level < 3: + comment.parent = parent + + comment.save() + + return JsonResponse({ + 'success': True, + 'comment': { + 'id': comment.id, + 'user': comment.user.username, + 'avatar_letter': comment.user.get_avatar_letter(), + 'content': comment.content, + 'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'), + 'likes': 0, + 'dislikes': 0, + } + }) + + return JsonResponse({'success': False, 'errors': form.errors}, status=400) + + +VOTES_PER_IP_PER_DAY = 2 + + +def _toggle_vote(request, model, field, obj): + """Общая логика лайка/дизлайка: по аккаунту для вошедших, по ID посетителя для остальных.""" + try: + is_like = bool(json.loads(request.body).get('is_like', True)) + except (json.JSONDecodeError, AttributeError): + return None, JsonResponse({'success': False, 'error': 'Invalid JSON'}, status=400) + + qs = model.objects.filter(**{field: obj}) + if request.user.is_authenticated: + mine = qs.filter(user=request.user) + else: + mine = qs.filter(user__isnull=True, visitor_id=request.visitor_id) + vote = mine.first() + + if vote: + if vote.is_like == is_like: + vote.delete() + return None, None + vote.is_like = is_like + vote.save(update_fields=['is_like']) + return is_like, None + + ip_hash = client_ip_hash(request) + if not request.user.is_authenticated and ip_hash: + recent = qs.filter(user__isnull=True, ip_hash=ip_hash, + created_at__gte=timezone.now() - timedelta(days=1)).count() + if recent >= VOTES_PER_IP_PER_DAY: + return None, JsonResponse( + {'success': False, 'error': 'Слишком много оценок с этого устройства, попробуйте позже'}, status=429 + ) + model.objects.create( + **{field: obj}, is_like=is_like, ip_hash=ip_hash, + user=request.user if request.user.is_authenticated else None, + visitor_id='' if request.user.is_authenticated else request.visitor_id, + ) + return is_like, None + + +@require_POST +def toggle_article_like(request, article_id): + """Лайк/дизлайк статьи (в том числе достижения), без регистрации.""" + article = get_object_or_404(Article, id=article_id, is_published=True) + user_vote, error = _toggle_vote(request, ArticleLike, 'article', article) + if error: + return error + return JsonResponse({ + 'success': True, + 'likes': article.likes_count, + 'dislikes': article.dislikes_count, + 'user_vote': user_vote + }) + + +@require_POST +def toggle_comment_like(request, comment_id): + """Лайк/дизлайк комментария, без регистрации.""" + comment = get_object_or_404(Comment, id=comment_id, is_approved=True) + user_vote, error = _toggle_vote(request, CommentLike, 'comment', comment) + if error: + return error + return JsonResponse({ + 'success': True, + 'likes': comment.likes_count, + 'dislikes': comment.dislikes_count, + 'user_vote': user_vote + }) + + +def handler404(request, exception): + """Обработчик ошибки 404.""" + return render(request, 'errors/404.html', status=404) + + +def handler500(request): + """Обработчик ошибки 500.""" return render(request, 'errors/500.html', status=500) \ No newline at end of file diff --git a/manage.py b/manage.py index 4999b48..66cf8a9 100644 --- a/manage.py +++ b/manage.py @@ -1,22 +1,22 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dspace.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/media/favicon.ico b/media/favicon.ico deleted file mode 100644 index 8f012a6..0000000 Binary files a/media/favicon.ico and /dev/null differ diff --git a/media/sitemap.xml b/media/sitemap.xml deleted file mode 100644 index 73642cb..0000000 --- a/media/sitemap.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - https://deev.space - 1.0 - - \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2f37ac5..5962630 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ -Django>=4.2,<5.0 -Pillow>=10.0.0 -python-slugify>=8.0.0 -Unidecode>=1.3.0 -bleach>=6.0.0 -requests>=2.31.0 -python-telegram-bot>=20.0 -vk-api>=11.9.9 -django-recaptcha>=3.0.0 -gunicorn>=21.0.0 +Django>=4.2,<5.0 +Pillow>=10.0.0 +python-slugify>=8.0.0 +Unidecode>=1.3.0 +bleach>=6.0.0 +requests>=2.31.0 +python-telegram-bot>=20.0 +vk-api>=11.9.9 +django-recaptcha>=3.0.0 +gunicorn>=21.0.0 beautifulsoup4>=4.12.0 \ No newline at end of file diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..69b3533 --- /dev/null +++ b/robots.txt @@ -0,0 +1,15 @@ +User-agent: * +Allow: / +Disallow: /admin/ +Disallow: /api/ +Disallow: /backups/ +Disallow: /dspace/ +Disallow: /logs/ +Disallow: /main/ +Disallow: /media/ +Disallow: /static/ +Disallow: /staticfiles/ +Disallow: /templates/ +Disallow: /venv/ + +Sitemap: https://deev.space/sitemap.xml \ No newline at end of file diff --git a/static/admin/css/autocomplete.css b/static/admin/css/autocomplete.css deleted file mode 100644 index 69c94e7..0000000 --- a/static/admin/css/autocomplete.css +++ /dev/null @@ -1,275 +0,0 @@ -select.admin-autocomplete { - width: 20em; -} - -.select2-container--admin-autocomplete.select2-container { - min-height: 30px; -} - -.select2-container--admin-autocomplete .select2-selection--single, -.select2-container--admin-autocomplete .select2-selection--multiple { - min-height: 30px; - padding: 0; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection, -.select2-container--admin-autocomplete.select2-container--open .select2-selection { - border-color: var(--body-quiet-color); - min-height: 30px; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, -.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { - padding: 0; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, -.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { - padding: 0; -} - -.select2-container--admin-autocomplete .select2-selection--single { - background-color: var(--body-bg); - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { - color: var(--body-fg); - line-height: 30px; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { - color: var(--body-quiet-color); -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { - height: 26px; - position: absolute; - top: 1px; - right: 1px; - width: 20px; -} - -.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { - border-color: #888 transparent transparent transparent; - border-style: solid; - border-width: 5px 4px 0 4px; - height: 0; - left: 50%; - margin-left: -4px; - margin-top: -2px; - position: absolute; - top: 50%; - width: 0; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { - float: left; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { - left: 1px; - right: auto; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { - background-color: var(--darkened-bg); - cursor: default; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { - display: none; -} - -.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent #888 transparent; - border-width: 0 4px 5px 4px; -} - -.select2-container--admin-autocomplete .select2-selection--multiple { - background-color: var(--body-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: text; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { - box-sizing: border-box; - list-style: none; - margin: 0; - padding: 0 10px 5px 5px; - width: 100%; - display: flex; - flex-wrap: wrap; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { - list-style: none; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { - color: var(--body-quiet-color); - margin-top: 5px; - float: left; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; - margin: 5px; - position: absolute; - right: 0; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { - background-color: var(--darkened-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: default; - float: left; - margin-right: 5px; - margin-top: 5px; - padding: 0 5px; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { - color: var(--body-quiet-color); - cursor: pointer; - display: inline-block; - font-weight: bold; - margin-right: 2px; -} - -.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { - color: var(--body-fg); -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { - float: right; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { - margin-left: 5px; - margin-right: auto; -} - -.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { - margin-left: 2px; - margin-right: auto; -} - -.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { - border: solid var(--body-quiet-color) 1px; - outline: 0; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { - background-color: var(--darkened-bg); - cursor: default; -} - -.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { - display: none; -} - -.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -.select2-container--admin-autocomplete .select2-search--dropdown { - background: var(--darkened-bg); -} - -.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { - background: var(--body-bg); - color: var(--body-fg); - border: 1px solid var(--border-color); - border-radius: 4px; -} - -.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { - background: transparent; - color: var(--body-fg); - border: none; - outline: 0; - box-shadow: none; - -webkit-appearance: textfield; -} - -.select2-container--admin-autocomplete .select2-results > .select2-results__options { - max-height: 200px; - overflow-y: auto; - color: var(--body-fg); - background: var(--body-bg); -} - -.select2-container--admin-autocomplete .select2-results__option[role=group] { - padding: 0; -} - -.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { - color: var(--body-quiet-color); -} - -.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { - background-color: var(--selected-bg); - color: var(--body-fg); -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option { - padding-left: 1em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { - padding-left: 0; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { - margin-left: -1em; - padding-left: 2em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -2em; - padding-left: 3em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -3em; - padding-left: 4em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -4em; - padding-left: 5em; -} - -.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -5em; - padding-left: 6em; -} - -.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { - background-color: var(--primary); - color: var(--primary-fg); -} - -.select2-container--admin-autocomplete .select2-results__group { - cursor: default; - display: block; - padding: 6px; -} diff --git a/static/admin/css/base.css b/static/admin/css/base.css deleted file mode 100644 index 93db7d0..0000000 --- a/static/admin/css/base.css +++ /dev/null @@ -1,1145 +0,0 @@ -/* - DJANGO Admin styles -*/ - -/* VARIABLE DEFINITIONS */ -html[data-theme="light"], -:root { - --primary: #79aec8; - --secondary: #417690; - --accent: #f5dd5d; - --primary-fg: #fff; - - --body-fg: #333; - --body-bg: #fff; - --body-quiet-color: #666; - --body-loud-color: #000; - - --header-color: #ffc; - --header-branding-color: var(--accent); - --header-bg: var(--secondary); - --header-link-color: var(--primary-fg); - - --breadcrumbs-fg: #c4dce8; - --breadcrumbs-link-fg: var(--body-bg); - --breadcrumbs-bg: var(--primary); - - --link-fg: #417893; - --link-hover-color: #036; - --link-selected-fg: #5b80b2; - - --hairline-color: #e8e8e8; - --border-color: #ccc; - - --error-fg: #ba2121; - - --message-success-bg: #dfd; - --message-warning-bg: #ffc; - --message-error-bg: #ffefef; - - --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ - --selected-bg: #e4e4e4; /* E.g. selected table cells */ - --selected-row: #ffc; - - --button-fg: #fff; - --button-bg: var(--primary); - --button-hover-bg: #609ab6; - --default-button-bg: var(--secondary); - --default-button-hover-bg: #205067; - --close-button-bg: #747474; - --close-button-hover-bg: #333; - --delete-button-bg: #ba2121; - --delete-button-hover-bg: #a41515; - - --object-tools-fg: var(--button-fg); - --object-tools-bg: var(--close-button-bg); - --object-tools-hover-bg: var(--close-button-hover-bg); - - --font-family-primary: - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - Roboto, - "Helvetica Neue", - Arial, - sans-serif, - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji"; - --font-family-monospace: - ui-monospace, - Menlo, - Monaco, - "Cascadia Mono", - "Segoe UI Mono", - "Roboto Mono", - "Oxygen Mono", - "Ubuntu Monospace", - "Source Code Pro", - "Fira Mono", - "Droid Sans Mono", - "Courier New", - monospace, - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji"; -} - -html, body { - height: 100%; -} - -body { - margin: 0; - padding: 0; - font-size: 0.875rem; - font-family: var(--font-family-primary); - color: var(--body-fg); - background: var(--body-bg); -} - -/* LINKS */ - -a:link, a:visited { - color: var(--link-fg); - text-decoration: none; - transition: color 0.15s, background 0.15s; -} - -a:focus, a:hover { - color: var(--link-hover-color); -} - -a:focus { - text-decoration: underline; -} - -a img { - border: none; -} - -a.section:link, a.section:visited { - color: var(--header-link-color); - text-decoration: none; -} - -a.section:focus, a.section:hover { - text-decoration: underline; -} - -/* GLOBAL DEFAULTS */ - -p, ol, ul, dl { - margin: .2em 0 .8em 0; -} - -p { - padding: 0; - line-height: 140%; -} - -h1,h2,h3,h4,h5 { - font-weight: bold; -} - -h1 { - margin: 0 0 20px; - font-weight: 300; - font-size: 1.25rem; - color: var(--body-quiet-color); -} - -h2 { - font-size: 1rem; - margin: 1em 0 .5em 0; -} - -h2.subhead { - font-weight: normal; - margin-top: 0; -} - -h3 { - font-size: 0.875rem; - margin: .8em 0 .3em 0; - color: var(--body-quiet-color); - font-weight: bold; -} - -h4 { - font-size: 0.75rem; - margin: 1em 0 .8em 0; - padding-bottom: 3px; -} - -h5 { - font-size: 0.625rem; - margin: 1.5em 0 .5em 0; - color: var(--body-quiet-color); - text-transform: uppercase; - letter-spacing: 1px; -} - -ul > li { - list-style-type: square; - padding: 1px 0; -} - -li ul { - margin-bottom: 0; -} - -li, dt, dd { - font-size: 0.8125rem; - line-height: 1.25rem; -} - -dt { - font-weight: bold; - margin-top: 4px; -} - -dd { - margin-left: 0; -} - -form { - margin: 0; - padding: 0; -} - -fieldset { - margin: 0; - min-width: 0; - padding: 0; - border: none; - border-top: 1px solid var(--hairline-color); -} - -blockquote { - font-size: 0.6875rem; - color: #777; - margin-left: 2px; - padding-left: 10px; - border-left: 5px solid #ddd; -} - -code, pre { - font-family: var(--font-family-monospace); - color: var(--body-quiet-color); - font-size: 0.75rem; - overflow-x: auto; -} - -pre.literal-block { - margin: 10px; - background: var(--darkened-bg); - padding: 6px 8px; -} - -code strong { - color: #930; -} - -hr { - clear: both; - color: var(--hairline-color); - background-color: var(--hairline-color); - height: 1px; - border: none; - margin: 0; - padding: 0; - line-height: 1px; -} - -/* TEXT STYLES & MODIFIERS */ - -.small { - font-size: 0.6875rem; -} - -.mini { - font-size: 0.625rem; -} - -.help, p.help, form p.help, div.help, form div.help, div.help li { - font-size: 0.6875rem; - color: var(--body-quiet-color); -} - -div.help ul { - margin-bottom: 0; -} - -.help-tooltip { - cursor: help; -} - -p img, h1 img, h2 img, h3 img, h4 img, td img { - vertical-align: middle; -} - -.quiet, a.quiet:link, a.quiet:visited { - color: var(--body-quiet-color); - font-weight: normal; -} - -.clear { - clear: both; -} - -.nowrap { - white-space: nowrap; -} - -.hidden { - display: none !important; -} - -/* TABLES */ - -table { - border-collapse: collapse; - border-color: var(--border-color); -} - -td, th { - font-size: 0.8125rem; - line-height: 1rem; - border-bottom: 1px solid var(--hairline-color); - vertical-align: top; - padding: 8px; -} - -th { - font-weight: 600; - text-align: left; -} - -thead th, -tfoot td { - color: var(--body-quiet-color); - padding: 5px 10px; - font-size: 0.6875rem; - background: var(--body-bg); - border: none; - border-top: 1px solid var(--hairline-color); - border-bottom: 1px solid var(--hairline-color); -} - -tfoot td { - border-bottom: none; - border-top: 1px solid var(--hairline-color); -} - -thead th.required { - color: var(--body-loud-color); -} - -tr.alt { - background: var(--darkened-bg); -} - -tr:nth-child(odd), .row-form-errors { - background: var(--body-bg); -} - -tr:nth-child(even), -tr:nth-child(even) .errorlist, -tr:nth-child(odd) + .row-form-errors, -tr:nth-child(odd) + .row-form-errors .errorlist { - background: var(--darkened-bg); -} - -/* SORTABLE TABLES */ - -thead th { - padding: 5px 10px; - line-height: normal; - text-transform: uppercase; - background: var(--darkened-bg); -} - -thead th a:link, thead th a:visited { - color: var(--body-quiet-color); -} - -thead th.sorted { - background: var(--selected-bg); -} - -thead th.sorted .text { - padding-right: 42px; -} - -table thead th .text span { - padding: 8px 10px; - display: block; -} - -table thead th .text a { - display: block; - cursor: pointer; - padding: 8px 10px; -} - -table thead th .text a:focus, table thead th .text a:hover { - background: var(--selected-bg); -} - -thead th.sorted a.sortremove { - visibility: hidden; -} - -table thead th.sorted:hover a.sortremove { - visibility: visible; -} - -table thead th.sorted .sortoptions { - display: block; - padding: 9px 5px 0 5px; - float: right; - text-align: right; -} - -table thead th.sorted .sortpriority { - font-size: .8em; - min-width: 12px; - text-align: center; - vertical-align: 3px; - margin-left: 2px; - margin-right: 2px; -} - -table thead th.sorted .sortoptions a { - position: relative; - width: 14px; - height: 14px; - display: inline-block; - background: url(../img/sorting-icons.svg) 0 0 no-repeat; - background-size: 14px auto; -} - -table thead th.sorted .sortoptions a.sortremove { - background-position: 0 0; -} - -table thead th.sorted .sortoptions a.sortremove:after { - content: '\\'; - position: absolute; - top: -6px; - left: 3px; - font-weight: 200; - font-size: 1.125rem; - color: var(--body-quiet-color); -} - -table thead th.sorted .sortoptions a.sortremove:focus:after, -table thead th.sorted .sortoptions a.sortremove:hover:after { - color: var(--link-fg); -} - -table thead th.sorted .sortoptions a.sortremove:focus, -table thead th.sorted .sortoptions a.sortremove:hover { - background-position: 0 -14px; -} - -table thead th.sorted .sortoptions a.ascending { - background-position: 0 -28px; -} - -table thead th.sorted .sortoptions a.ascending:focus, -table thead th.sorted .sortoptions a.ascending:hover { - background-position: 0 -42px; -} - -table thead th.sorted .sortoptions a.descending { - top: 1px; - background-position: 0 -56px; -} - -table thead th.sorted .sortoptions a.descending:focus, -table thead th.sorted .sortoptions a.descending:hover { - background-position: 0 -70px; -} - -/* FORM DEFAULTS */ - -input, textarea, select, .form-row p, form .button { - margin: 2px 0; - padding: 2px 3px; - vertical-align: middle; - font-family: var(--font-family-primary); - font-weight: normal; - font-size: 0.8125rem; -} -.form-row div.help { - padding: 2px 3px; -} - -textarea { - vertical-align: top; -} - -input[type=text], input[type=password], input[type=email], input[type=url], -input[type=number], input[type=tel], textarea, select, .vTextField { - border: 1px solid var(--border-color); - border-radius: 4px; - padding: 5px 6px; - margin-top: 0; - color: var(--body-fg); - background-color: var(--body-bg); -} - -input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, -input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, -textarea:focus, select:focus, .vTextField:focus { - border-color: var(--body-quiet-color); -} - -select { - height: 1.875rem; -} - -select[multiple] { - /* Allow HTML size attribute to override the height in the rule above. */ - height: auto; - min-height: 150px; -} - -/* FORM BUTTONS */ - -.button, input[type=submit], input[type=button], .submit-row input, a.button { - background: var(--button-bg); - padding: 10px 15px; - border: none; - border-radius: 4px; - color: var(--button-fg); - cursor: pointer; - transition: background 0.15s; -} - -a.button { - padding: 4px 5px; -} - -.button:active, input[type=submit]:active, input[type=button]:active, -.button:focus, input[type=submit]:focus, input[type=button]:focus, -.button:hover, input[type=submit]:hover, input[type=button]:hover { - background: var(--button-hover-bg); -} - -.button[disabled], input[type=submit][disabled], input[type=button][disabled] { - opacity: 0.4; -} - -.button.default, input[type=submit].default, .submit-row input.default { - border: none; - font-weight: 400; - background: var(--default-button-bg); -} - -.button.default:active, input[type=submit].default:active, -.button.default:focus, input[type=submit].default:focus, -.button.default:hover, input[type=submit].default:hover { - background: var(--default-button-hover-bg); -} - -.button[disabled].default, -input[type=submit][disabled].default, -input[type=button][disabled].default { - opacity: 0.4; -} - - -/* MODULES */ - -.module { - border: none; - margin-bottom: 30px; - background: var(--body-bg); -} - -.module p, .module ul, .module h3, .module h4, .module dl, .module pre { - padding-left: 10px; - padding-right: 10px; -} - -.module blockquote { - margin-left: 12px; -} - -.module ul, .module ol { - margin-left: 1.5em; -} - -.module h3 { - margin-top: .6em; -} - -.module h2, .module caption, .inline-group h2 { - margin: 0; - padding: 8px; - font-weight: 400; - font-size: 0.8125rem; - text-align: left; - background: var(--primary); - color: var(--header-link-color); -} - -.module caption, -.inline-group h2 { - font-size: 0.75rem; - letter-spacing: 0.5px; - text-transform: uppercase; -} - -.module table { - border-collapse: collapse; -} - -/* MESSAGES & ERRORS */ - -ul.messagelist { - padding: 0; - margin: 0; -} - -ul.messagelist li { - display: block; - font-weight: 400; - font-size: 0.8125rem; - padding: 10px 10px 10px 65px; - margin: 0 0 10px 0; - background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; - background-size: 16px auto; - color: var(--body-fg); - word-break: break-word; -} - -ul.messagelist li.warning { - background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; - background-size: 14px auto; -} - -ul.messagelist li.error { - background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; - background-size: 16px auto; -} - -.errornote { - font-size: 0.875rem; - font-weight: 700; - display: block; - padding: 10px 12px; - margin: 0 0 10px 0; - color: var(--error-fg); - border: 1px solid var(--error-fg); - border-radius: 4px; - background-color: var(--body-bg); - background-position: 5px 12px; - overflow-wrap: break-word; -} - -ul.errorlist { - margin: 0 0 4px; - padding: 0; - color: var(--error-fg); - background: var(--body-bg); -} - -ul.errorlist li { - font-size: 0.8125rem; - display: block; - margin-bottom: 4px; - overflow-wrap: break-word; -} - -ul.errorlist li:first-child { - margin-top: 0; -} - -ul.errorlist li a { - color: inherit; - text-decoration: underline; -} - -td ul.errorlist { - margin: 0; - padding: 0; -} - -td ul.errorlist li { - margin: 0; -} - -.form-row.errors { - margin: 0; - border: none; - border-bottom: 1px solid var(--hairline-color); - background: none; -} - -.form-row.errors ul.errorlist li { - padding-left: 0; -} - -.errors input, .errors select, .errors textarea, -td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { - border: 1px solid var(--error-fg); -} - -.description { - font-size: 0.75rem; - padding: 5px 0 0 12px; -} - -/* BREADCRUMBS */ - -div.breadcrumbs { - background: var(--breadcrumbs-bg); - padding: 10px 40px; - border: none; - color: var(--breadcrumbs-fg); - text-align: left; -} - -div.breadcrumbs a { - color: var(--breadcrumbs-link-fg); -} - -div.breadcrumbs a:focus, div.breadcrumbs a:hover { - color: var(--breadcrumbs-fg); -} - -/* ACTION ICONS */ - -.viewlink, .inlineviewlink { - padding-left: 16px; - background: url(../img/icon-viewlink.svg) 0 1px no-repeat; -} - -.addlink { - padding-left: 16px; - background: url(../img/icon-addlink.svg) 0 1px no-repeat; -} - -.changelink, .inlinechangelink { - padding-left: 16px; - background: url(../img/icon-changelink.svg) 0 1px no-repeat; -} - -.deletelink { - padding-left: 16px; - background: url(../img/icon-deletelink.svg) 0 1px no-repeat; -} - -a.deletelink:link, a.deletelink:visited { - color: #CC3434; /* XXX Probably unused? */ -} - -a.deletelink:focus, a.deletelink:hover { - color: #993333; /* XXX Probably unused? */ - text-decoration: none; -} - -/* OBJECT TOOLS */ - -.object-tools { - font-size: 0.625rem; - font-weight: bold; - padding-left: 0; - float: right; - position: relative; - margin-top: -48px; -} - -.object-tools li { - display: block; - float: left; - margin-left: 5px; - height: 1rem; -} - -.object-tools a { - border-radius: 15px; -} - -.object-tools a:link, .object-tools a:visited { - display: block; - float: left; - padding: 3px 12px; - background: var(--object-tools-bg); - color: var(--object-tools-fg); - font-weight: 400; - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.object-tools a:focus, .object-tools a:hover { - background-color: var(--object-tools-hover-bg); -} - -.object-tools a:focus{ - text-decoration: none; -} - -.object-tools a.viewsitelink, .object-tools a.addlink { - background-repeat: no-repeat; - background-position: right 7px center; - padding-right: 26px; -} - -.object-tools a.viewsitelink { - background-image: url(../img/tooltag-arrowright.svg); -} - -.object-tools a.addlink { - background-image: url(../img/tooltag-add.svg); -} - -/* OBJECT HISTORY */ - -#change-history table { - width: 100%; -} - -#change-history table tbody th { - width: 16em; -} - -#change-history .paginator { - color: var(--body-quiet-color); - border-bottom: 1px solid var(--hairline-color); - background: var(--body-bg); - overflow: hidden; -} - -/* PAGE STRUCTURE */ - -#container { - position: relative; - width: 100%; - min-width: 980px; - padding: 0; - display: flex; - flex-direction: column; - height: 100%; -} - -#container > div { - flex-shrink: 0; -} - -#container > .main { - display: flex; - flex: 1 0 auto; -} - -.main > .content { - flex: 1 0; - max-width: 100%; -} - -.skip-to-content-link { - position: absolute; - top: -999px; - margin: 5px; - padding: 5px; - background: var(--body-bg); - z-index: 1; -} - -.skip-to-content-link:focus { - left: 0px; - top: 0px; -} - -#content { - padding: 20px 40px; -} - -.dashboard #content { - width: 600px; -} - -#content-main { - float: left; - width: 100%; -} - -#content-related { - float: right; - width: 260px; - position: relative; - margin-right: -300px; -} - -#footer { - clear: both; - padding: 10px; -} - -/* COLUMN TYPES */ - -.colMS { - margin-right: 300px; -} - -.colSM { - margin-left: 300px; -} - -.colSM #content-related { - float: left; - margin-right: 0; - margin-left: -300px; -} - -.colSM #content-main { - float: right; -} - -.popup .colM { - width: auto; -} - -/* HEADER */ - -#header { - width: auto; - height: auto; - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 40px; - background: var(--header-bg); - color: var(--header-color); - overflow: hidden; -} - -#header a:link, #header a:visited, #logout-form button { - color: var(--header-link-color); -} - -#header a:focus , #header a:hover { - text-decoration: underline; -} - -#branding { - display: flex; -} - -#branding h1 { - padding: 0; - margin: 0; - margin-inline-end: 20px; - font-weight: 300; - font-size: 1.5rem; - color: var(--header-branding-color); -} - -#branding h1 a:link, #branding h1 a:visited { - color: var(--accent); -} - -#branding h2 { - padding: 0 10px; - font-size: 0.875rem; - margin: -8px 0 8px 0; - font-weight: normal; - color: var(--header-color); -} - -#branding a:hover { - text-decoration: none; -} - -#logout-form { - display: inline; -} - -#logout-form button { - background: none; - border: 0; - cursor: pointer; - font-family: var(--font-family-primary); -} - -#user-tools { - float: right; - margin: 0 0 0 20px; - text-align: right; -} - -#user-tools, #logout-form button{ - padding: 0; - font-weight: 300; - font-size: 0.6875rem; - letter-spacing: 0.5px; - text-transform: uppercase; -} - -#user-tools a, #logout-form button { - border-bottom: 1px solid rgba(255, 255, 255, 0.25); -} - -#user-tools a:focus, #user-tools a:hover, -#logout-form button:active, #logout-form button:hover { - text-decoration: none; - border-bottom: 0; -} - -#logout-form button:active, #logout-form button:hover { - margin-bottom: 1px; -} - -/* SIDEBAR */ - -#content-related { - background: var(--darkened-bg); -} - -#content-related .module { - background: none; -} - -#content-related h3 { - color: var(--body-quiet-color); - padding: 0 16px; - margin: 0 0 16px; -} - -#content-related h4 { - font-size: 0.8125rem; -} - -#content-related p { - padding-left: 16px; - padding-right: 16px; -} - -#content-related .actionlist { - padding: 0; - margin: 16px; -} - -#content-related .actionlist li { - line-height: 1.2; - margin-bottom: 10px; - padding-left: 18px; -} - -#content-related .module h2 { - background: none; - padding: 16px; - margin-bottom: 16px; - border-bottom: 1px solid var(--hairline-color); - font-size: 1.125rem; - color: var(--body-fg); -} - -.delete-confirmation form input[type="submit"] { - background: var(--delete-button-bg); - border-radius: 4px; - padding: 10px 15px; - color: var(--button-fg); -} - -.delete-confirmation form input[type="submit"]:active, -.delete-confirmation form input[type="submit"]:focus, -.delete-confirmation form input[type="submit"]:hover { - background: var(--delete-button-hover-bg); -} - -.delete-confirmation form .cancel-link { - display: inline-block; - vertical-align: middle; - height: 0.9375rem; - line-height: 0.9375rem; - border-radius: 4px; - padding: 10px 15px; - color: var(--button-fg); - background: var(--close-button-bg); - margin: 0 0 0 10px; -} - -.delete-confirmation form .cancel-link:active, -.delete-confirmation form .cancel-link:focus, -.delete-confirmation form .cancel-link:hover { - background: var(--close-button-hover-bg); -} - -/* POPUP */ -.popup #content { - padding: 20px; -} - -.popup #container { - min-width: 0; -} - -.popup #header { - padding: 10px 20px; -} - -/* PAGINATOR */ - -.paginator { - display: flex; - align-items: center; - gap: 4px; - font-size: 0.8125rem; - padding-top: 10px; - padding-bottom: 10px; - line-height: 22px; - margin: 0; - border-top: 1px solid var(--hairline-color); - width: 100%; -} - -.paginator a:link, .paginator a:visited { - padding: 2px 6px; - background: var(--button-bg); - text-decoration: none; - color: var(--button-fg); -} - -.paginator a.showall { - border: none; - background: none; - color: var(--link-fg); -} - -.paginator a.showall:focus, .paginator a.showall:hover { - background: none; - color: var(--link-hover-color); -} - -.paginator .end { - margin-right: 6px; -} - -.paginator .this-page { - padding: 2px 6px; - font-weight: bold; - font-size: 0.8125rem; - vertical-align: top; -} - -.paginator a:focus, .paginator a:hover { - color: white; - background: var(--link-hover-color); -} - -.paginator input { - margin-left: auto; -} - -.base-svgs { - display: none; -} diff --git a/static/admin/css/changelists.css b/static/admin/css/changelists.css deleted file mode 100644 index a754513..0000000 --- a/static/admin/css/changelists.css +++ /dev/null @@ -1,328 +0,0 @@ -/* CHANGELISTS */ - -#changelist { - display: flex; - align-items: flex-start; - justify-content: space-between; -} - -#changelist .changelist-form-container { - flex: 1 1 auto; - min-width: 0; -} - -#changelist table { - width: 100%; -} - -.change-list .hiddenfields { display:none; } - -.change-list .filtered table { - border-right: none; -} - -.change-list .filtered { - min-height: 400px; -} - -.change-list .filtered .results, .change-list .filtered .paginator, -.filtered #toolbar, .filtered div.xfull { - width: auto; -} - -.change-list .filtered table tbody th { - padding-right: 1em; -} - -#changelist-form .results { - overflow-x: auto; - width: 100%; -} - -#changelist .toplinks { - border-bottom: 1px solid var(--hairline-color); -} - -#changelist .paginator { - color: var(--body-quiet-color); - border-bottom: 1px solid var(--hairline-color); - background: var(--body-bg); - overflow: hidden; -} - -/* CHANGELIST TABLES */ - -#changelist table thead th { - padding: 0; - white-space: nowrap; - vertical-align: middle; -} - -#changelist table thead th.action-checkbox-column { - width: 1.5em; - text-align: center; -} - -#changelist table tbody td.action-checkbox { - text-align: center; -} - -#changelist table tfoot { - color: var(--body-quiet-color); -} - -/* TOOLBAR */ - -#toolbar { - padding: 8px 10px; - margin-bottom: 15px; - border-top: 1px solid var(--hairline-color); - border-bottom: 1px solid var(--hairline-color); - background: var(--darkened-bg); - color: var(--body-quiet-color); -} - -#toolbar form input { - border-radius: 4px; - font-size: 0.875rem; - padding: 5px; - color: var(--body-fg); -} - -#toolbar #searchbar { - height: 1.1875rem; - border: 1px solid var(--border-color); - padding: 2px 5px; - margin: 0; - vertical-align: top; - font-size: 0.8125rem; - max-width: 100%; -} - -#toolbar #searchbar:focus { - border-color: var(--body-quiet-color); -} - -#toolbar form input[type="submit"] { - border: 1px solid var(--border-color); - font-size: 0.8125rem; - padding: 4px 8px; - margin: 0; - vertical-align: middle; - background: var(--body-bg); - box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; - cursor: pointer; - color: var(--body-fg); -} - -#toolbar form input[type="submit"]:focus, -#toolbar form input[type="submit"]:hover { - border-color: var(--body-quiet-color); -} - -#changelist-search img { - vertical-align: middle; - margin-right: 4px; -} - -#changelist-search .help { - word-break: break-word; -} - -/* FILTER COLUMN */ - -#changelist-filter { - flex: 0 0 240px; - order: 1; - background: var(--darkened-bg); - border-left: none; - margin: 0 0 0 30px; -} - -#changelist-filter h2 { - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 5px 15px; - margin-bottom: 12px; - border-bottom: none; -} - -#changelist-filter h3, -#changelist-filter details summary { - font-weight: 400; - padding: 0 15px; - margin-bottom: 10px; -} - -#changelist-filter details summary > * { - display: inline; -} - -#changelist-filter details > summary { - list-style-type: none; -} - -#changelist-filter details > summary::-webkit-details-marker { - display: none; -} - -#changelist-filter details > summary::before { - content: '→'; - font-weight: bold; - color: var(--link-hover-color); -} - -#changelist-filter details[open] > summary::before { - content: '↓'; -} - -#changelist-filter ul { - margin: 5px 0; - padding: 0 15px 15px; - border-bottom: 1px solid var(--hairline-color); -} - -#changelist-filter ul:last-child { - border-bottom: none; -} - -#changelist-filter li { - list-style-type: none; - margin-left: 0; - padding-left: 0; -} - -#changelist-filter a { - display: block; - color: var(--body-quiet-color); - word-break: break-word; -} - -#changelist-filter li.selected { - border-left: 5px solid var(--hairline-color); - padding-left: 10px; - margin-left: -15px; -} - -#changelist-filter li.selected a { - color: var(--link-selected-fg); -} - -#changelist-filter a:focus, #changelist-filter a:hover, -#changelist-filter li.selected a:focus, -#changelist-filter li.selected a:hover { - color: var(--link-hover-color); -} - -#changelist-filter #changelist-filter-clear a { - font-size: 0.8125rem; - padding-bottom: 10px; - border-bottom: 1px solid var(--hairline-color); -} - -/* DATE DRILLDOWN */ - -.change-list .toplinks { - display: flex; - padding-bottom: 5px; - flex-wrap: wrap; - gap: 3px 17px; - font-weight: bold; -} - -.change-list .toplinks a { - font-size: 0.8125rem; -} - -.change-list .toplinks .date-back { - color: var(--body-quiet-color); -} - -.change-list .toplinks .date-back:focus, -.change-list .toplinks .date-back:hover { - color: var(--link-hover-color); -} - -/* ACTIONS */ - -.filtered .actions { - border-right: none; -} - -#changelist table input { - margin: 0; - vertical-align: baseline; -} - -/* Once the :has() pseudo-class is supported by all browsers, the tr.selected - selector and the JS adding the class can be removed. */ -#changelist tbody tr.selected { - background-color: var(--selected-row); -} - -#changelist tbody tr:has(.action-select:checked) { - background-color: var(--selected-row); -} - -#changelist .actions { - padding: 10px; - background: var(--body-bg); - border-top: none; - border-bottom: none; - line-height: 1.5rem; - color: var(--body-quiet-color); - width: 100%; -} - -#changelist .actions span.all, -#changelist .actions span.action-counter, -#changelist .actions span.clear, -#changelist .actions span.question { - font-size: 0.8125rem; - margin: 0 0.5em; -} - -#changelist .actions:last-child { - border-bottom: none; -} - -#changelist .actions select { - vertical-align: top; - height: 1.5rem; - color: var(--body-fg); - border: 1px solid var(--border-color); - border-radius: 4px; - font-size: 0.875rem; - padding: 0 0 0 4px; - margin: 0; - margin-left: 10px; -} - -#changelist .actions select:focus { - border-color: var(--body-quiet-color); -} - -#changelist .actions label { - display: inline-block; - vertical-align: middle; - font-size: 0.8125rem; -} - -#changelist .actions .button { - font-size: 0.8125rem; - border: 1px solid var(--border-color); - border-radius: 4px; - background: var(--body-bg); - box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; - cursor: pointer; - height: 1.5rem; - line-height: 1; - padding: 4px 8px; - margin: 0; - color: var(--body-fg); -} - -#changelist .actions .button:focus, #changelist .actions .button:hover { - border-color: var(--body-quiet-color); -} diff --git a/static/admin/css/dark_mode.css b/static/admin/css/dark_mode.css deleted file mode 100644 index 6d08233..0000000 --- a/static/admin/css/dark_mode.css +++ /dev/null @@ -1,137 +0,0 @@ -@media (prefers-color-scheme: dark) { - :root { - --primary: #264b5d; - --primary-fg: #f7f7f7; - - --body-fg: #eeeeee; - --body-bg: #121212; - --body-quiet-color: #e0e0e0; - --body-loud-color: #ffffff; - - --breadcrumbs-link-fg: #e0e0e0; - --breadcrumbs-bg: var(--primary); - - --link-fg: #81d4fa; - --link-hover-color: #4ac1f7; - --link-selected-fg: #6f94c6; - - --hairline-color: #272727; - --border-color: #353535; - - --error-fg: #e35f5f; - --message-success-bg: #006b1b; - --message-warning-bg: #583305; - --message-error-bg: #570808; - - --darkened-bg: #212121; - --selected-bg: #1b1b1b; - --selected-row: #00363a; - - --close-button-bg: #333333; - --close-button-hover-bg: #666666; - } - } - - -html[data-theme="dark"] { - --primary: #264b5d; - --primary-fg: #f7f7f7; - - --body-fg: #eeeeee; - --body-bg: #121212; - --body-quiet-color: #e0e0e0; - --body-loud-color: #ffffff; - - --breadcrumbs-link-fg: #e0e0e0; - --breadcrumbs-bg: var(--primary); - - --link-fg: #81d4fa; - --link-hover-color: #4ac1f7; - --link-selected-fg: #6f94c6; - - --hairline-color: #272727; - --border-color: #353535; - - --error-fg: #e35f5f; - --message-success-bg: #006b1b; - --message-warning-bg: #583305; - --message-error-bg: #570808; - - --darkened-bg: #212121; - --selected-bg: #1b1b1b; - --selected-row: #00363a; - - --close-button-bg: #333333; - --close-button-hover-bg: #666666; -} - -/* THEME SWITCH */ -.theme-toggle { - cursor: pointer; - border: none; - padding: 0; - background: transparent; - vertical-align: middle; - margin-inline-start: 5px; - margin-top: -1px; -} - -.theme-toggle svg { - vertical-align: middle; - height: 1rem; - width: 1rem; - display: none; -} - -/* -Fully hide screen reader text so we only show the one matching the current -theme. -*/ -.theme-toggle .visually-hidden { - display: none; -} - -html[data-theme="auto"] .theme-toggle .theme-label-when-auto { - display: block; -} - -html[data-theme="dark"] .theme-toggle .theme-label-when-dark { - display: block; -} - -html[data-theme="light"] .theme-toggle .theme-label-when-light { - display: block; -} - -/* ICONS */ -.theme-toggle svg.theme-icon-when-auto, -.theme-toggle svg.theme-icon-when-dark, -.theme-toggle svg.theme-icon-when-light { - fill: var(--header-link-color); - color: var(--header-bg); -} - -html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { - display: block; -} - -html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { - display: block; -} - -html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { - display: block; -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip: rect(0,0,0,0); - white-space: nowrap; - border: 0; - color: var(--body-fg); - background-color: var(--body-bg); -} diff --git a/static/admin/css/dashboard.css b/static/admin/css/dashboard.css deleted file mode 100644 index 242b81a..0000000 --- a/static/admin/css/dashboard.css +++ /dev/null @@ -1,29 +0,0 @@ -/* DASHBOARD */ -.dashboard td, .dashboard th { - word-break: break-word; -} - -.dashboard .module table th { - width: 100%; -} - -.dashboard .module table td { - white-space: nowrap; -} - -.dashboard .module table td a { - display: block; - padding-right: .6em; -} - -/* RECENT ACTIONS MODULE */ - -.module ul.actionlist { - margin-left: 0; -} - -ul.actionlist li { - list-style-type: none; - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/static/admin/css/forms.css b/static/admin/css/forms.css deleted file mode 100644 index 9a8dad0..0000000 --- a/static/admin/css/forms.css +++ /dev/null @@ -1,534 +0,0 @@ -@import url('widgets.css'); - -/* FORM ROWS */ - -.form-row { - overflow: hidden; - padding: 10px; - font-size: 0.8125rem; - border-bottom: 1px solid var(--hairline-color); -} - -.form-row img, .form-row input { - vertical-align: middle; -} - -.form-row label input[type="checkbox"] { - margin-top: 0; - vertical-align: 0; -} - -form .form-row p { - padding-left: 0; -} - -.flex-container { - display: flex; -} - -.form-multiline { - flex-wrap: wrap; -} - -.form-multiline > div { - padding-bottom: 10px; -} - -/* FORM LABELS */ - -label { - font-weight: normal; - color: var(--body-quiet-color); - font-size: 0.8125rem; -} - -.required label, label.required { - font-weight: bold; - color: var(--body-fg); -} - -/* RADIO BUTTONS */ - -form div.radiolist div { - padding-right: 7px; -} - -form div.radiolist.inline div { - display: inline-block; -} - -form div.radiolist label { - width: auto; -} - -form div.radiolist input[type="radio"] { - margin: -2px 4px 0 0; - padding: 0; -} - -form ul.inline { - margin-left: 0; - padding: 0; -} - -form ul.inline li { - float: left; - padding-right: 7px; -} - -/* ALIGNED FIELDSETS */ - -.aligned label { - display: block; - padding: 4px 10px 0 0; - min-width: 160px; - width: 160px; - word-wrap: break-word; - line-height: 1; -} - -.aligned label:not(.vCheckboxLabel):after { - content: ''; - display: inline-block; - vertical-align: middle; - height: 1.625rem; -} - -.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { - padding: 6px 0; - margin-top: 0; - margin-bottom: 0; - margin-left: 0; - overflow-wrap: break-word; -} - -.aligned ul label { - display: inline; - float: none; - width: auto; -} - -.aligned .form-row input { - margin-bottom: 0; -} - -.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { - width: 350px; -} - -form .aligned ul { - margin-left: 160px; - padding-left: 10px; -} - -form .aligned div.radiolist { - display: inline-block; - margin: 0; - padding: 0; -} - -form .aligned p.help, -form .aligned div.help { - margin-top: 0; - margin-left: 160px; - padding-left: 10px; -} - -form .aligned p.date div.help.timezonewarning, -form .aligned p.datetime div.help.timezonewarning, -form .aligned p.time div.help.timezonewarning { - margin-left: 0; - padding-left: 0; - font-weight: normal; -} - -form .aligned p.help:last-child, -form .aligned div.help:last-child { - margin-bottom: 0; - padding-bottom: 0; -} - -form .aligned input + p.help, -form .aligned textarea + p.help, -form .aligned select + p.help, -form .aligned input + div.help, -form .aligned textarea + div.help, -form .aligned select + div.help { - margin-left: 160px; - padding-left: 10px; -} - -form .aligned ul li { - list-style: none; -} - -form .aligned table p { - margin-left: 0; - padding-left: 0; -} - -.aligned .vCheckboxLabel { - float: none; - width: auto; - display: inline-block; - vertical-align: -3px; - padding: 0 0 5px 5px; -} - -.aligned .vCheckboxLabel + p.help, -.aligned .vCheckboxLabel + div.help { - margin-top: -4px; -} - -.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { - width: 610px; -} - -fieldset .fieldBox { - margin-right: 20px; -} - -/* WIDE FIELDSETS */ - -.wide label { - width: 200px; -} - -form .wide p, -form .wide ul.errorlist, -form .wide input + p.help, -form .wide input + div.help { - margin-left: 200px; -} - -form .wide p.help, -form .wide div.help { - padding-left: 50px; -} - -form div.help ul { - padding-left: 0; - margin-left: 0; -} - -.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { - width: 450px; -} - -/* COLLAPSED FIELDSETS */ - -fieldset.collapsed * { - display: none; -} - -fieldset.collapsed h2, fieldset.collapsed { - display: block; -} - -fieldset.collapsed { - border: 1px solid var(--hairline-color); - border-radius: 4px; - overflow: hidden; -} - -fieldset.collapsed h2 { - background: var(--darkened-bg); - color: var(--body-quiet-color); -} - -fieldset .collapse-toggle { - color: var(--header-link-color); -} - -fieldset.collapsed .collapse-toggle { - background: transparent; - display: inline; - color: var(--link-fg); -} - -/* MONOSPACE TEXTAREAS */ - -fieldset.monospace textarea { - font-family: var(--font-family-monospace); -} - -/* SUBMIT ROW */ - -.submit-row { - padding: 12px 14px 12px; - margin: 0 0 20px; - background: var(--darkened-bg); - border: 1px solid var(--hairline-color); - border-radius: 4px; - overflow: hidden; - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -body.popup .submit-row { - overflow: auto; -} - -.submit-row input { - height: 2.1875rem; - line-height: 0.9375rem; -} - -.submit-row input, .submit-row a { - margin: 0; -} - -.submit-row input.default { - text-transform: uppercase; -} - -.submit-row a.deletelink { - margin-left: auto; -} - -.submit-row a.deletelink { - display: block; - background: var(--delete-button-bg); - border-radius: 4px; - padding: 0.625rem 0.9375rem; - height: 0.9375rem; - line-height: 0.9375rem; - color: var(--button-fg); -} - -.submit-row a.closelink { - display: inline-block; - background: var(--close-button-bg); - border-radius: 4px; - padding: 10px 15px; - height: 0.9375rem; - line-height: 0.9375rem; - color: var(--button-fg); -} - -.submit-row a.deletelink:focus, -.submit-row a.deletelink:hover, -.submit-row a.deletelink:active { - background: var(--delete-button-hover-bg); - text-decoration: none; -} - -.submit-row a.closelink:focus, -.submit-row a.closelink:hover, -.submit-row a.closelink:active { - background: var(--close-button-hover-bg); - text-decoration: none; -} - -/* CUSTOM FORM FIELDS */ - -.vSelectMultipleField { - vertical-align: top; -} - -.vCheckboxField { - border: none; -} - -.vDateField, .vTimeField { - margin-right: 2px; - margin-bottom: 4px; -} - -.vDateField { - min-width: 6.85em; -} - -.vTimeField { - min-width: 4.7em; -} - -.vURLField { - width: 30em; -} - -.vLargeTextField, .vXMLLargeTextField { - width: 48em; -} - -.flatpages-flatpage #id_content { - height: 40.2em; -} - -.module table .vPositiveSmallIntegerField { - width: 2.2em; -} - -.vIntegerField { - width: 5em; -} - -.vBigIntegerField { - width: 10em; -} - -.vForeignKeyRawIdAdminField { - width: 5em; -} - -.vTextField, .vUUIDField { - width: 20em; -} - -/* INLINES */ - -.inline-group { - padding: 0; - margin: 0 0 30px; -} - -.inline-group thead th { - padding: 8px 10px; -} - -.inline-group .aligned label { - width: 160px; -} - -.inline-related { - position: relative; -} - -.inline-related h3 { - margin: 0; - color: var(--body-quiet-color); - padding: 5px; - font-size: 0.8125rem; - background: var(--darkened-bg); - border-top: 1px solid var(--hairline-color); - border-bottom: 1px solid var(--hairline-color); -} - -.inline-related h3 span.delete { - float: right; -} - -.inline-related h3 span.delete label { - margin-left: 2px; - font-size: 0.6875rem; -} - -.inline-related fieldset { - margin: 0; - background: var(--body-bg); - border: none; - width: 100%; -} - -.inline-related fieldset.module h3 { - margin: 0; - padding: 2px 5px 3px 5px; - font-size: 0.6875rem; - text-align: left; - font-weight: bold; - background: #bcd; - color: var(--body-bg); -} - -.inline-group .tabular fieldset.module { - border: none; -} - -.inline-related.tabular fieldset.module table { - width: 100%; - overflow-x: scroll; -} - -.last-related fieldset { - border: none; -} - -.inline-group .tabular tr.has_original td { - padding-top: 2em; -} - -.inline-group .tabular tr td.original { - padding: 2px 0 0 0; - width: 0; - _position: relative; -} - -.inline-group .tabular th.original { - width: 0px; - padding: 0; -} - -.inline-group .tabular td.original p { - position: absolute; - left: 0; - height: 1.1em; - padding: 2px 9px; - overflow: hidden; - font-size: 0.5625rem; - font-weight: bold; - color: var(--body-quiet-color); - _width: 700px; -} - -.inline-group ul.tools { - padding: 0; - margin: 0; - list-style: none; -} - -.inline-group ul.tools li { - display: inline; - padding: 0 5px; -} - -.inline-group div.add-row, -.inline-group .tabular tr.add-row td { - color: var(--body-quiet-color); - background: var(--darkened-bg); - padding: 8px 10px; - border-bottom: 1px solid var(--hairline-color); -} - -.inline-group .tabular tr.add-row td { - padding: 8px 10px; - border-bottom: 1px solid var(--hairline-color); -} - -.inline-group ul.tools a.add, -.inline-group div.add-row a, -.inline-group .tabular tr.add-row td a { - background: url(../img/icon-addlink.svg) 0 1px no-repeat; - padding-left: 16px; - font-size: 0.75rem; -} - -.empty-form { - display: none; -} - -/* RELATED FIELD ADD ONE / LOOKUP */ - -.related-lookup { - margin-left: 5px; - display: inline-block; - vertical-align: middle; - background-repeat: no-repeat; - background-size: 14px; -} - -.related-lookup { - width: 1rem; - height: 1rem; - background-image: url(../img/search.svg); -} - -form .related-widget-wrapper ul { - display: inline-block; - margin-left: 0; - padding-left: 0; -} - -.clearable-file-input input { - margin-top: 0; -} diff --git a/static/admin/css/login.css b/static/admin/css/login.css deleted file mode 100644 index 389772f..0000000 --- a/static/admin/css/login.css +++ /dev/null @@ -1,61 +0,0 @@ -/* LOGIN FORM */ - -.login { - background: var(--darkened-bg); - height: auto; -} - -.login #header { - height: auto; - padding: 15px 16px; - justify-content: center; -} - -.login #header h1 { - font-size: 1.125rem; - margin: 0; -} - -.login #header h1 a { - color: var(--header-link-color); -} - -.login #content { - padding: 20px 20px 0; -} - -.login #container { - background: var(--body-bg); - border: 1px solid var(--hairline-color); - border-radius: 4px; - overflow: hidden; - width: 28em; - min-width: 300px; - margin: 100px auto; - height: auto; -} - -.login .form-row { - padding: 4px 0; -} - -.login .form-row label { - display: block; - line-height: 2em; -} - -.login .form-row #id_username, .login .form-row #id_password { - padding: 8px; - width: 100%; - box-sizing: border-box; -} - -.login .submit-row { - padding: 1em 0 0 0; - margin: 0; - text-align: center; -} - -.login .password-reset-link { - text-align: center; -} diff --git a/static/admin/css/nav_sidebar.css b/static/admin/css/nav_sidebar.css deleted file mode 100644 index f76e6ce..0000000 --- a/static/admin/css/nav_sidebar.css +++ /dev/null @@ -1,144 +0,0 @@ -.sticky { - position: sticky; - top: 0; - max-height: 100vh; -} - -.toggle-nav-sidebar { - z-index: 20; - left: 0; - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 23px; - width: 23px; - border: 0; - border-right: 1px solid var(--hairline-color); - background-color: var(--body-bg); - cursor: pointer; - font-size: 1.25rem; - color: var(--link-fg); - padding: 0; -} - -[dir="rtl"] .toggle-nav-sidebar { - border-left: 1px solid var(--hairline-color); - border-right: 0; -} - -.toggle-nav-sidebar:hover, -.toggle-nav-sidebar:focus { - background-color: var(--darkened-bg); -} - -#nav-sidebar { - z-index: 15; - flex: 0 0 275px; - left: -276px; - margin-left: -276px; - border-top: 1px solid transparent; - border-right: 1px solid var(--hairline-color); - background-color: var(--body-bg); - overflow: auto; -} - -[dir="rtl"] #nav-sidebar { - border-left: 1px solid var(--hairline-color); - border-right: 0; - left: 0; - margin-left: 0; - right: -276px; - margin-right: -276px; -} - -.toggle-nav-sidebar::before { - content: '\00BB'; -} - -.main.shifted .toggle-nav-sidebar::before { - content: '\00AB'; -} - -.main > #nav-sidebar { - visibility: hidden; -} - -.main.shifted > #nav-sidebar { - margin-left: 0; - visibility: visible; -} - -[dir="rtl"] .main.shifted > #nav-sidebar { - margin-right: 0; -} - -#nav-sidebar .module th { - width: 100%; - overflow-wrap: anywhere; -} - -#nav-sidebar .module th, -#nav-sidebar .module caption { - padding-left: 16px; -} - -#nav-sidebar .module td { - white-space: nowrap; -} - -[dir="rtl"] #nav-sidebar .module th, -[dir="rtl"] #nav-sidebar .module caption { - padding-left: 8px; - padding-right: 16px; -} - -#nav-sidebar .current-app .section:link, -#nav-sidebar .current-app .section:visited { - color: var(--header-color); - font-weight: bold; -} - -#nav-sidebar .current-model { - background: var(--selected-row); -} - -.main > #nav-sidebar + .content { - max-width: calc(100% - 23px); -} - -.main.shifted > #nav-sidebar + .content { - max-width: calc(100% - 299px); -} - -@media (max-width: 767px) { - #nav-sidebar, #toggle-nav-sidebar { - display: none; - } - - .main > #nav-sidebar + .content, - .main.shifted > #nav-sidebar + .content { - max-width: 100%; - } -} - -#nav-filter { - width: 100%; - box-sizing: border-box; - padding: 2px 5px; - margin: 5px 0; - border: 1px solid var(--border-color); - background-color: var(--darkened-bg); - color: var(--body-fg); -} - -#nav-filter:focus { - border-color: var(--body-quiet-color); -} - -#nav-filter.no-results { - background: var(--message-error-bg); -} - -#nav-sidebar table { - width: 100%; -} diff --git a/static/admin/css/responsive.css b/static/admin/css/responsive.css deleted file mode 100644 index 1d0a188..0000000 --- a/static/admin/css/responsive.css +++ /dev/null @@ -1,999 +0,0 @@ -/* Tablets */ - -input[type="submit"], button { - -webkit-appearance: none; - appearance: none; -} - -@media (max-width: 1024px) { - /* Basic */ - - html { - -webkit-text-size-adjust: 100%; - } - - td, th { - padding: 10px; - font-size: 0.875rem; - } - - .small { - font-size: 0.75rem; - } - - /* Layout */ - - #container { - min-width: 0; - } - - #content { - padding: 15px 20px 20px; - } - - div.breadcrumbs { - padding: 10px 30px; - } - - /* Header */ - - #header { - flex-direction: column; - padding: 15px 30px; - justify-content: flex-start; - } - - #branding h1 { - margin: 0 0 8px; - line-height: 1.2; - } - - #user-tools { - margin: 0; - font-weight: 400; - line-height: 1.85; - text-align: left; - } - - #user-tools a { - display: inline-block; - line-height: 1.4; - } - - /* Dashboard */ - - .dashboard #content { - width: auto; - } - - #content-related { - margin-right: -290px; - } - - .colSM #content-related { - margin-left: -290px; - } - - .colMS { - margin-right: 290px; - } - - .colSM { - margin-left: 290px; - } - - .dashboard .module table td a { - padding-right: 0; - } - - td .changelink, td .addlink { - font-size: 0.8125rem; - } - - /* Changelist */ - - #toolbar { - border: none; - padding: 15px; - } - - #changelist-search > div { - display: flex; - flex-wrap: nowrap; - max-width: 480px; - } - - #changelist-search label { - line-height: 1.375rem; - } - - #toolbar form #searchbar { - flex: 1 0 auto; - width: 0; - height: 1.375rem; - margin: 0 10px 0 6px; - } - - #toolbar form input[type=submit] { - flex: 0 1 auto; - } - - #changelist-search .quiet { - width: 0; - flex: 1 0 auto; - margin: 5px 0 0 25px; - } - - #changelist .actions { - display: flex; - flex-wrap: wrap; - padding: 15px 0; - } - - #changelist .actions label { - display: flex; - } - - #changelist .actions select { - background: var(--body-bg); - } - - #changelist .actions .button { - min-width: 48px; - margin: 0 10px; - } - - #changelist .actions span.all, - #changelist .actions span.clear, - #changelist .actions span.question, - #changelist .actions span.action-counter { - font-size: 0.6875rem; - margin: 0 10px 0 0; - } - - #changelist-filter { - flex-basis: 200px; - } - - .change-list .filtered .results, - .change-list .filtered .paginator, - .filtered #toolbar, - .filtered .actions, - - #changelist .paginator { - border-top-color: var(--hairline-color); /* XXX Is this used at all? */ - } - - #changelist .results + .paginator { - border-top: none; - } - - /* Forms */ - - label { - font-size: 0.875rem; - } - - .form-row input[type=text], - .form-row input[type=password], - .form-row input[type=email], - .form-row input[type=url], - .form-row input[type=tel], - .form-row input[type=number], - .form-row textarea, - .form-row select, - .form-row .vTextField { - box-sizing: border-box; - margin: 0; - padding: 6px 8px; - min-height: 2.25rem; - font-size: 0.875rem; - } - - .form-row select { - height: 2.25rem; - } - - .form-row select[multiple] { - height: auto; - min-height: 0; - } - - fieldset .fieldBox + .fieldBox { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--hairline-color); - } - - textarea { - max-width: 100%; - max-height: 120px; - } - - .aligned label { - padding-top: 6px; - } - - .aligned .related-lookup, - .aligned .datetimeshortcuts, - .aligned .related-lookup + strong { - align-self: center; - margin-left: 15px; - } - - form .aligned div.radiolist { - margin-left: 2px; - } - - .submit-row { - padding: 8px; - } - - .submit-row a.deletelink { - padding: 10px 7px; - } - - .button, input[type=submit], input[type=button], .submit-row input, a.button { - padding: 7px; - } - - /* Related widget */ - - .related-widget-wrapper { - float: none; - } - - .related-widget-wrapper-link + .selector { - max-width: calc(100% - 30px); - margin-right: 15px; - } - - select + .related-widget-wrapper-link, - .related-widget-wrapper-link + .related-widget-wrapper-link { - margin-left: 10px; - } - - /* Selector */ - - .selector { - display: flex; - width: 100%; - } - - .selector .selector-filter { - display: flex; - align-items: center; - } - - .selector .selector-filter label { - margin: 0 8px 0 0; - } - - .selector .selector-filter input { - width: auto; - min-height: 0; - flex: 1 1; - } - - .selector-available, .selector-chosen { - width: auto; - flex: 1 1; - display: flex; - flex-direction: column; - } - - .selector select { - width: 100%; - flex: 1 0 auto; - margin-bottom: 5px; - } - - .selector ul.selector-chooser { - width: 26px; - height: 52px; - padding: 2px 0; - margin: auto 15px; - border-radius: 20px; - transform: translateY(-10px); - } - - .selector-add, .selector-remove { - width: 20px; - height: 20px; - background-size: 20px auto; - } - - .selector-add { - background-position: 0 -120px; - } - - .selector-remove { - background-position: 0 -80px; - } - - a.selector-chooseall, a.selector-clearall { - align-self: center; - } - - .stacked { - flex-direction: column; - max-width: 480px; - } - - .stacked > * { - flex: 0 1 auto; - } - - .stacked select { - margin-bottom: 0; - } - - .stacked .selector-available, .stacked .selector-chosen { - width: auto; - } - - .stacked ul.selector-chooser { - width: 52px; - height: 26px; - padding: 0 2px; - margin: 15px auto; - transform: none; - } - - .stacked .selector-chooser li { - padding: 3px; - } - - .stacked .selector-add, .stacked .selector-remove { - background-size: 20px auto; - } - - .stacked .selector-add { - background-position: 0 -40px; - } - - .stacked .active.selector-add { - background-position: 0 -40px; - } - - .active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -140px; - } - - .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { - background-position: 0 -60px; - } - - .stacked .selector-remove { - background-position: 0 0; - } - - .stacked .active.selector-remove { - background-position: 0 0; - } - - .active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -100px; - } - - .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { - background-position: 0 -20px; - } - - .help-tooltip, .selector .help-icon { - display: none; - } - - .datetime input { - width: 50%; - max-width: 120px; - } - - .datetime span { - font-size: 0.8125rem; - } - - .datetime .timezonewarning { - display: block; - font-size: 0.6875rem; - color: var(--body-quiet-color); - } - - .datetimeshortcuts { - color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ - } - - .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { - width: 75%; - } - - .inline-group { - overflow: auto; - } - - /* Messages */ - - ul.messagelist li { - padding-left: 55px; - background-position: 30px 12px; - } - - ul.messagelist li.error { - background-position: 30px 12px; - } - - ul.messagelist li.warning { - background-position: 30px 14px; - } - - /* Login */ - - .login #header { - padding: 15px 20px; - } - - .login #branding h1 { - margin: 0; - } - - /* GIS */ - - div.olMap { - max-width: calc(100vw - 30px); - max-height: 300px; - } - - .olMap + .clear_features { - display: block; - margin-top: 10px; - } - - /* Docs */ - - .module table.xfull { - width: 100%; - } - - pre.literal-block { - overflow: auto; - } -} - -/* Mobile */ - -@media (max-width: 767px) { - /* Layout */ - - #header, #content, #footer { - padding: 15px; - } - - #footer:empty { - padding: 0; - } - - div.breadcrumbs { - padding: 10px 15px; - } - - /* Dashboard */ - - .colMS, .colSM { - margin: 0; - } - - #content-related, .colSM #content-related { - width: 100%; - margin: 0; - } - - #content-related .module { - margin-bottom: 0; - } - - #content-related .module h2 { - padding: 10px 15px; - font-size: 1rem; - } - - /* Changelist */ - - #changelist { - align-items: stretch; - flex-direction: column; - } - - #toolbar { - padding: 10px; - } - - #changelist-filter { - margin-left: 0; - } - - #changelist .actions label { - flex: 1 1; - } - - #changelist .actions select { - flex: 1 0; - width: 100%; - } - - #changelist .actions span { - flex: 1 0 100%; - } - - #changelist-filter { - position: static; - width: auto; - margin-top: 30px; - } - - .object-tools { - float: none; - margin: 0 0 15px; - padding: 0; - overflow: hidden; - } - - .object-tools li { - height: auto; - margin-left: 0; - } - - .object-tools li + li { - margin-left: 15px; - } - - /* Forms */ - - .form-row { - padding: 15px 0; - } - - .aligned .form-row, - .aligned .form-row > div { - max-width: 100vw; - } - - .aligned .form-row > div { - width: calc(100vw - 30px); - } - - .flex-container { - flex-flow: column; - } - - .flex-container.checkbox-row { - flex-flow: row; - } - - textarea { - max-width: none; - } - - .vURLField { - width: auto; - } - - fieldset .fieldBox + .fieldBox { - margin-top: 15px; - padding-top: 15px; - } - - fieldset.collapsed .form-row { - display: none; - } - - .aligned label { - width: 100%; - min-width: auto; - padding: 0 0 10px; - } - - .aligned label:after { - max-height: 0; - } - - .aligned .form-row input, - .aligned .form-row select, - .aligned .form-row textarea { - flex: 1 1 auto; - max-width: 100%; - } - - .aligned .checkbox-row input { - flex: 0 1 auto; - margin: 0; - } - - .aligned .vCheckboxLabel { - flex: 1 0; - padding: 1px 0 0 5px; - } - - .aligned label + p, - .aligned label + div.help, - .aligned label + div.readonly { - padding: 0; - margin-left: 0; - } - - .aligned p.file-upload { - font-size: 0.8125rem; - } - - span.clearable-file-input { - margin-left: 15px; - } - - span.clearable-file-input label { - font-size: 0.8125rem; - padding-bottom: 0; - } - - .aligned .timezonewarning { - flex: 1 0 100%; - margin-top: 5px; - } - - form .aligned .form-row div.help { - width: 100%; - margin: 5px 0 0; - padding: 0; - } - - form .aligned ul, - form .aligned ul.errorlist { - margin-left: 0; - padding-left: 0; - } - - form .aligned div.radiolist { - margin-top: 5px; - margin-right: 15px; - margin-bottom: -3px; - } - - form .aligned div.radiolist:not(.inline) div + div { - margin-top: 5px; - } - - /* Related widget */ - - .related-widget-wrapper { - width: 100%; - display: flex; - align-items: flex-start; - } - - .related-widget-wrapper .selector { - order: 1; - } - - .related-widget-wrapper > a { - order: 2; - } - - .related-widget-wrapper .radiolist ~ a { - align-self: flex-end; - } - - .related-widget-wrapper > select ~ a { - align-self: center; - } - - select + .related-widget-wrapper-link, - .related-widget-wrapper-link + .related-widget-wrapper-link { - margin-left: 15px; - } - - /* Selector */ - - .selector { - flex-direction: column; - } - - .selector > * { - float: none; - } - - .selector-available, .selector-chosen { - margin-bottom: 0; - flex: 1 1 auto; - } - - .selector select { - max-height: 96px; - } - - .selector ul.selector-chooser { - display: block; - float: none; - width: 52px; - height: 26px; - padding: 0 2px; - margin: 15px auto 20px; - transform: none; - } - - .selector ul.selector-chooser li { - float: left; - } - - .selector-remove { - background-position: 0 0; - } - - .active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -20px; - } - - .selector-add { - background-position: 0 -40px; - } - - .active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -60px; - } - - /* Inlines */ - - .inline-group[data-inline-type="stacked"] .inline-related { - border: 1px solid var(--hairline-color); - border-radius: 4px; - margin-top: 15px; - overflow: auto; - } - - .inline-group[data-inline-type="stacked"] .inline-related > * { - box-sizing: border-box; - } - - .inline-group[data-inline-type="stacked"] .inline-related .module { - padding: 0 10px; - } - - .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { - border-top: 1px solid var(--hairline-color); - border-bottom: none; - } - - .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { - border-top: none; - } - - .inline-group[data-inline-type="stacked"] .inline-related h3 { - padding: 10px; - border-top-width: 0; - border-bottom-width: 2px; - display: flex; - flex-wrap: wrap; - align-items: center; - } - - .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { - margin-right: auto; - } - - .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { - float: none; - flex: 1 1 100%; - margin-top: 5px; - } - - .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { - width: 100%; - } - - .inline-group[data-inline-type="stacked"] .aligned label { - width: 100%; - } - - .inline-group[data-inline-type="stacked"] div.add-row { - margin-top: 15px; - border: 1px solid var(--hairline-color); - border-radius: 4px; - } - - .inline-group div.add-row, - .inline-group .tabular tr.add-row td { - padding: 0; - } - - .inline-group div.add-row a, - .inline-group .tabular tr.add-row td a { - display: block; - padding: 8px 10px 8px 26px; - background-position: 8px 9px; - } - - /* Submit row */ - - .submit-row { - padding: 10px; - margin: 0 0 15px; - flex-direction: column; - gap: 8px; - } - - .submit-row input, .submit-row input.default, .submit-row a { - text-align: center; - } - - .submit-row a.closelink { - padding: 10px 0; - text-align: center; - } - - .submit-row a.deletelink { - margin: 0; - } - - /* Messages */ - - ul.messagelist li { - padding-left: 40px; - background-position: 15px 12px; - } - - ul.messagelist li.error { - background-position: 15px 12px; - } - - ul.messagelist li.warning { - background-position: 15px 14px; - } - - /* Paginator */ - - .paginator .this-page, .paginator a:link, .paginator a:visited { - padding: 4px 10px; - } - - /* Login */ - - body.login { - padding: 0 15px; - } - - .login #container { - width: auto; - max-width: 480px; - margin: 50px auto; - } - - .login #header, - .login #content { - padding: 15px; - } - - .login #content-main { - float: none; - } - - .login .form-row { - padding: 0; - } - - .login .form-row + .form-row { - margin-top: 15px; - } - - .login .form-row label { - margin: 0 0 5px; - line-height: 1.2; - } - - .login .submit-row { - padding: 15px 0 0; - } - - .login br { - display: none; - } - - .login .submit-row input { - margin: 0; - text-transform: uppercase; - } - - .errornote { - margin: 0 0 20px; - padding: 8px 12px; - font-size: 0.8125rem; - } - - /* Calendar and clock */ - - .calendarbox, .clockbox { - position: fixed !important; - top: 50% !important; - left: 50% !important; - transform: translate(-50%, -50%); - margin: 0; - border: none; - overflow: visible; - } - - .calendarbox:before, .clockbox:before { - content: ''; - position: fixed; - top: 50%; - left: 50%; - width: 100vw; - height: 100vh; - background: rgba(0, 0, 0, 0.75); - transform: translate(-50%, -50%); - } - - .calendarbox > *, .clockbox > * { - position: relative; - z-index: 1; - } - - .calendarbox > div:first-child { - z-index: 2; - } - - .calendarbox .calendar, .clockbox h2 { - border-radius: 4px 4px 0 0; - overflow: hidden; - } - - .calendarbox .calendar-cancel, .clockbox .calendar-cancel { - border-radius: 0 0 4px 4px; - overflow: hidden; - } - - .calendar-shortcuts { - padding: 10px 0; - font-size: 0.75rem; - line-height: 0.75rem; - } - - .calendar-shortcuts a { - margin: 0 4px; - } - - .timelist a { - background: var(--body-bg); - padding: 4px; - } - - .calendar-cancel { - padding: 8px 10px; - } - - .clockbox h2 { - padding: 8px 15px; - } - - .calendar caption { - padding: 10px; - } - - .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { - z-index: 1; - top: 10px; - } - - /* History */ - - table#change-history tbody th, table#change-history tbody td { - font-size: 0.8125rem; - word-break: break-word; - } - - table#change-history tbody th { - width: auto; - } - - /* Docs */ - - table.model tbody th, table.model tbody td { - font-size: 0.8125rem; - word-break: break-word; - } -} diff --git a/static/admin/css/responsive_rtl.css b/static/admin/css/responsive_rtl.css deleted file mode 100644 index 31dc8ff..0000000 --- a/static/admin/css/responsive_rtl.css +++ /dev/null @@ -1,84 +0,0 @@ -/* TABLETS */ - -@media (max-width: 1024px) { - [dir="rtl"] .colMS { - margin-right: 0; - } - - [dir="rtl"] #user-tools { - text-align: right; - } - - [dir="rtl"] #changelist .actions label { - padding-left: 10px; - padding-right: 0; - } - - [dir="rtl"] #changelist .actions select { - margin-left: 0; - margin-right: 15px; - } - - [dir="rtl"] .change-list .filtered .results, - [dir="rtl"] .change-list .filtered .paginator, - [dir="rtl"] .filtered #toolbar, - [dir="rtl"] .filtered div.xfull, - [dir="rtl"] .filtered .actions, - [dir="rtl"] #changelist-filter { - margin-left: 0; - } - - [dir="rtl"] .inline-group ul.tools a.add, - [dir="rtl"] .inline-group div.add-row a, - [dir="rtl"] .inline-group .tabular tr.add-row td a { - padding: 8px 26px 8px 10px; - background-position: calc(100% - 8px) 9px; - } - - [dir="rtl"] .related-widget-wrapper-link + .selector { - margin-right: 0; - margin-left: 15px; - } - - [dir="rtl"] .selector .selector-filter label { - margin-right: 0; - margin-left: 8px; - } - - [dir="rtl"] .object-tools li { - float: right; - } - - [dir="rtl"] .object-tools li + li { - margin-left: 0; - margin-right: 15px; - } - - [dir="rtl"] .dashboard .module table td a { - padding-left: 0; - padding-right: 16px; - } -} - -/* MOBILE */ - -@media (max-width: 767px) { - [dir="rtl"] .aligned .related-lookup, - [dir="rtl"] .aligned .datetimeshortcuts { - margin-left: 0; - margin-right: 15px; - } - - [dir="rtl"] .aligned ul, - [dir="rtl"] form .aligned ul.errorlist { - margin-right: 0; - } - - [dir="rtl"] #changelist-filter { - margin-left: 0; - margin-right: 0; - } - [dir="rtl"] .aligned .vCheckboxLabel { - padding: 1px 5px 0 0; - } -} diff --git a/static/admin/css/rtl.css b/static/admin/css/rtl.css deleted file mode 100644 index c349a93..0000000 --- a/static/admin/css/rtl.css +++ /dev/null @@ -1,298 +0,0 @@ -/* GLOBAL */ - -th { - text-align: right; -} - -.module h2, .module caption { - text-align: right; -} - -.module ul, .module ol { - margin-left: 0; - margin-right: 1.5em; -} - -.viewlink, .addlink, .changelink { - padding-left: 0; - padding-right: 16px; - background-position: 100% 1px; -} - -.deletelink { - padding-left: 0; - padding-right: 16px; - background-position: 100% 1px; -} - -.object-tools { - float: left; -} - -thead th:first-child, -tfoot td:first-child { - border-left: none; -} - -/* LAYOUT */ - -#user-tools { - right: auto; - left: 0; - text-align: left; -} - -div.breadcrumbs { - text-align: right; -} - -#content-main { - float: right; -} - -#content-related { - float: left; - margin-left: -300px; - margin-right: auto; -} - -.colMS { - margin-left: 300px; - margin-right: 0; -} - -/* SORTABLE TABLES */ - -table thead th.sorted .sortoptions { - float: left; -} - -thead th.sorted .text { - padding-right: 0; - padding-left: 42px; -} - -/* dashboard styles */ - -.dashboard .module table td a { - padding-left: .6em; - padding-right: 16px; -} - -/* changelists styles */ - -.change-list .filtered table { - border-left: none; - border-right: 0px none; -} - -#changelist-filter { - border-left: none; - border-right: none; - margin-left: 0; - margin-right: 30px; -} - -#changelist-filter li.selected { - border-left: none; - padding-left: 10px; - margin-left: 0; - border-right: 5px solid var(--hairline-color); - padding-right: 10px; - margin-right: -15px; -} - -#changelist table tbody td:first-child, #changelist table tbody th:first-child { - border-right: none; - border-left: none; -} - -.paginator .end { - margin-left: 6px; - margin-right: 0; -} - -.paginator input { - margin-left: 0; - margin-right: auto; -} - -/* FORMS */ - -.aligned label { - padding: 0 0 3px 1em; -} - -.submit-row a.deletelink { - margin-left: 0; - margin-right: auto; -} - -.vDateField, .vTimeField { - margin-left: 2px; -} - -.aligned .form-row input { - margin-left: 5px; -} - -form .aligned ul { - margin-right: 163px; - padding-right: 10px; - margin-left: 0; - padding-left: 0; -} - -form ul.inline li { - float: right; - padding-right: 0; - padding-left: 7px; -} - -form .aligned p.help, -form .aligned div.help { - margin-right: 160px; - padding-right: 10px; -} - -form div.help ul, -form .aligned .checkbox-row + .help, -form .aligned p.date div.help.timezonewarning, -form .aligned p.datetime div.help.timezonewarning, -form .aligned p.time div.help.timezonewarning { - margin-right: 0; - padding-right: 0; -} - -form .wide p.help, form .wide div.help { - padding-left: 0; - padding-right: 50px; -} - -form .wide p, -form .wide ul.errorlist, -form .wide input + p.help, -form .wide input + div.help { - margin-right: 200px; - margin-left: 0px; -} - -.submit-row { - text-align: right; -} - -fieldset .fieldBox { - margin-left: 20px; - margin-right: 0; -} - -.errorlist li { - background-position: 100% 12px; - padding: 0; -} - -.errornote { - background-position: 100% 12px; - padding: 10px 12px; -} - -/* WIDGETS */ - -.calendarnav-previous { - top: 0; - left: auto; - right: 10px; - background: url(../img/calendar-icons.svg) 0 -30px no-repeat; -} - -.calendarbox .calendarnav-previous:focus, -.calendarbox .calendarnav-previous:hover { - background-position: 0 -45px; -} - -.calendarnav-next { - top: 0; - right: auto; - left: 10px; - background: url(../img/calendar-icons.svg) 0 0 no-repeat; -} - -.calendarbox .calendarnav-next:focus, -.calendarbox .calendarnav-next:hover { - background-position: 0 -15px; -} - -.calendar caption, .calendarbox h2 { - text-align: center; -} - -.selector { - float: right; -} - -.selector .selector-filter { - text-align: right; -} - -.selector-add { - background: url(../img/selector-icons.svg) 0 -64px no-repeat; -} - -.active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -80px; -} - -.selector-remove { - background: url(../img/selector-icons.svg) 0 -96px no-repeat; -} - -.active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -112px; -} - -a.selector-chooseall { - background: url(../img/selector-icons.svg) right -128px no-repeat; -} - -a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { - background-position: 100% -144px; -} - -a.selector-clearall { - background: url(../img/selector-icons.svg) 0 -160px no-repeat; -} - -a.active.selector-clearall:focus, a.active.selector-clearall:hover { - background-position: 0 -176px; -} - -.inline-deletelink { - float: left; -} - -form .form-row p.datetime { - overflow: hidden; -} - -.related-widget-wrapper { - float: right; -} - -/* MISC */ - -.inline-related h2, .inline-group h2 { - text-align: right -} - -.inline-related h3 span.delete { - padding-right: 20px; - padding-left: inherit; - left: 10px; - right: inherit; - float:left; -} - -.inline-related h3 span.delete label { - margin-left: inherit; - margin-right: 2px; -} diff --git a/static/admin/css/vendor/select2/LICENSE-SELECT2.md b/static/admin/css/vendor/select2/LICENSE-SELECT2.md deleted file mode 100644 index 8cb8a2b..0000000 --- a/static/admin/css/vendor/select2/LICENSE-SELECT2.md +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/static/admin/css/vendor/select2/select2.css b/static/admin/css/vendor/select2/select2.css deleted file mode 100644 index 750b320..0000000 --- a/static/admin/css/vendor/select2/select2.css +++ /dev/null @@ -1,481 +0,0 @@ -.select2-container { - box-sizing: border-box; - display: inline-block; - margin: 0; - position: relative; - vertical-align: middle; } - .select2-container .select2-selection--single { - box-sizing: border-box; - cursor: pointer; - display: block; - height: 28px; - user-select: none; - -webkit-user-select: none; } - .select2-container .select2-selection--single .select2-selection__rendered { - display: block; - padding-left: 8px; - padding-right: 20px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } - .select2-container .select2-selection--single .select2-selection__clear { - position: relative; } - .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { - padding-right: 8px; - padding-left: 20px; } - .select2-container .select2-selection--multiple { - box-sizing: border-box; - cursor: pointer; - display: block; - min-height: 32px; - user-select: none; - -webkit-user-select: none; } - .select2-container .select2-selection--multiple .select2-selection__rendered { - display: inline-block; - overflow: hidden; - padding-left: 8px; - text-overflow: ellipsis; - white-space: nowrap; } - .select2-container .select2-search--inline { - float: left; } - .select2-container .select2-search--inline .select2-search__field { - box-sizing: border-box; - border: none; - font-size: 100%; - margin-top: 5px; - padding: 0; } - .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { - -webkit-appearance: none; } - -.select2-dropdown { - background-color: white; - border: 1px solid #aaa; - border-radius: 4px; - box-sizing: border-box; - display: block; - position: absolute; - left: -100000px; - width: 100%; - z-index: 1051; } - -.select2-results { - display: block; } - -.select2-results__options { - list-style: none; - margin: 0; - padding: 0; } - -.select2-results__option { - padding: 6px; - user-select: none; - -webkit-user-select: none; } - .select2-results__option[aria-selected] { - cursor: pointer; } - -.select2-container--open .select2-dropdown { - left: 0; } - -.select2-container--open .select2-dropdown--above { - border-bottom: none; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; } - -.select2-container--open .select2-dropdown--below { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; } - -.select2-search--dropdown { - display: block; - padding: 4px; } - .select2-search--dropdown .select2-search__field { - padding: 4px; - width: 100%; - box-sizing: border-box; } - .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { - -webkit-appearance: none; } - .select2-search--dropdown.select2-search--hide { - display: none; } - -.select2-close-mask { - border: 0; - margin: 0; - padding: 0; - display: block; - position: fixed; - left: 0; - top: 0; - min-height: 100%; - min-width: 100%; - height: auto; - width: auto; - opacity: 0; - z-index: 99; - background-color: #fff; - filter: alpha(opacity=0); } - -.select2-hidden-accessible { - border: 0 !important; - clip: rect(0 0 0 0) !important; - -webkit-clip-path: inset(50%) !important; - clip-path: inset(50%) !important; - height: 1px !important; - overflow: hidden !important; - padding: 0 !important; - position: absolute !important; - width: 1px !important; - white-space: nowrap !important; } - -.select2-container--default .select2-selection--single { - background-color: #fff; - border: 1px solid #aaa; - border-radius: 4px; } - .select2-container--default .select2-selection--single .select2-selection__rendered { - color: #444; - line-height: 28px; } - .select2-container--default .select2-selection--single .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; } - .select2-container--default .select2-selection--single .select2-selection__placeholder { - color: #999; } - .select2-container--default .select2-selection--single .select2-selection__arrow { - height: 26px; - position: absolute; - top: 1px; - right: 1px; - width: 20px; } - .select2-container--default .select2-selection--single .select2-selection__arrow b { - border-color: #888 transparent transparent transparent; - border-style: solid; - border-width: 5px 4px 0 4px; - height: 0; - left: 50%; - margin-left: -4px; - margin-top: -2px; - position: absolute; - top: 50%; - width: 0; } - -.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { - float: left; } - -.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { - left: 1px; - right: auto; } - -.select2-container--default.select2-container--disabled .select2-selection--single { - background-color: #eee; - cursor: default; } - .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { - display: none; } - -.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent #888 transparent; - border-width: 0 4px 5px 4px; } - -.select2-container--default .select2-selection--multiple { - background-color: white; - border: 1px solid #aaa; - border-radius: 4px; - cursor: text; } - .select2-container--default .select2-selection--multiple .select2-selection__rendered { - box-sizing: border-box; - list-style: none; - margin: 0; - padding: 0 5px; - width: 100%; } - .select2-container--default .select2-selection--multiple .select2-selection__rendered li { - list-style: none; } - .select2-container--default .select2-selection--multiple .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; - margin-top: 5px; - margin-right: 10px; - padding: 1px; } - .select2-container--default .select2-selection--multiple .select2-selection__choice { - background-color: #e4e4e4; - border: 1px solid #aaa; - border-radius: 4px; - cursor: default; - float: left; - margin-right: 5px; - margin-top: 5px; - padding: 0 5px; } - .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { - color: #999; - cursor: pointer; - display: inline-block; - font-weight: bold; - margin-right: 2px; } - .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { - color: #333; } - -.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { - float: right; } - -.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { - margin-left: 5px; - margin-right: auto; } - -.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { - margin-left: 2px; - margin-right: auto; } - -.select2-container--default.select2-container--focus .select2-selection--multiple { - border: solid black 1px; - outline: 0; } - -.select2-container--default.select2-container--disabled .select2-selection--multiple { - background-color: #eee; - cursor: default; } - -.select2-container--default.select2-container--disabled .select2-selection__choice__remove { - display: none; } - -.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { - border-top-left-radius: 0; - border-top-right-radius: 0; } - -.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; } - -.select2-container--default .select2-search--dropdown .select2-search__field { - border: 1px solid #aaa; } - -.select2-container--default .select2-search--inline .select2-search__field { - background: transparent; - border: none; - outline: 0; - box-shadow: none; - -webkit-appearance: textfield; } - -.select2-container--default .select2-results > .select2-results__options { - max-height: 200px; - overflow-y: auto; } - -.select2-container--default .select2-results__option[role=group] { - padding: 0; } - -.select2-container--default .select2-results__option[aria-disabled=true] { - color: #999; } - -.select2-container--default .select2-results__option[aria-selected=true] { - background-color: #ddd; } - -.select2-container--default .select2-results__option .select2-results__option { - padding-left: 1em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__group { - padding-left: 0; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option { - margin-left: -1em; - padding-left: 2em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -2em; - padding-left: 3em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -3em; - padding-left: 4em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -4em; - padding-left: 5em; } - .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { - margin-left: -5em; - padding-left: 6em; } - -.select2-container--default .select2-results__option--highlighted[aria-selected] { - background-color: #5897fb; - color: white; } - -.select2-container--default .select2-results__group { - cursor: default; - display: block; - padding: 6px; } - -.select2-container--classic .select2-selection--single { - background-color: #f7f7f7; - border: 1px solid #aaa; - border-radius: 4px; - outline: 0; - background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); - background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); - background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } - .select2-container--classic .select2-selection--single:focus { - border: 1px solid #5897fb; } - .select2-container--classic .select2-selection--single .select2-selection__rendered { - color: #444; - line-height: 28px; } - .select2-container--classic .select2-selection--single .select2-selection__clear { - cursor: pointer; - float: right; - font-weight: bold; - margin-right: 10px; } - .select2-container--classic .select2-selection--single .select2-selection__placeholder { - color: #999; } - .select2-container--classic .select2-selection--single .select2-selection__arrow { - background-color: #ddd; - border: none; - border-left: 1px solid #aaa; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - height: 26px; - position: absolute; - top: 1px; - right: 1px; - width: 20px; - background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); - background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); - background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } - .select2-container--classic .select2-selection--single .select2-selection__arrow b { - border-color: #888 transparent transparent transparent; - border-style: solid; - border-width: 5px 4px 0 4px; - height: 0; - left: 50%; - margin-left: -4px; - margin-top: -2px; - position: absolute; - top: 50%; - width: 0; } - -.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { - float: left; } - -.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { - border: none; - border-right: 1px solid #aaa; - border-radius: 0; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - left: 1px; - right: auto; } - -.select2-container--classic.select2-container--open .select2-selection--single { - border: 1px solid #5897fb; } - .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { - background: transparent; - border: none; } - .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { - border-color: transparent transparent #888 transparent; - border-width: 0 4px 5px 4px; } - -.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; - background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); - background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); - background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } - -.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { - border-bottom: none; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); - background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); - background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); - background-repeat: repeat-x; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } - -.select2-container--classic .select2-selection--multiple { - background-color: white; - border: 1px solid #aaa; - border-radius: 4px; - cursor: text; - outline: 0; } - .select2-container--classic .select2-selection--multiple:focus { - border: 1px solid #5897fb; } - .select2-container--classic .select2-selection--multiple .select2-selection__rendered { - list-style: none; - margin: 0; - padding: 0 5px; } - .select2-container--classic .select2-selection--multiple .select2-selection__clear { - display: none; } - .select2-container--classic .select2-selection--multiple .select2-selection__choice { - background-color: #e4e4e4; - border: 1px solid #aaa; - border-radius: 4px; - cursor: default; - float: left; - margin-right: 5px; - margin-top: 5px; - padding: 0 5px; } - .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { - color: #888; - cursor: pointer; - display: inline-block; - font-weight: bold; - margin-right: 2px; } - .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { - color: #555; } - -.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { - float: right; - margin-left: 5px; - margin-right: auto; } - -.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { - margin-left: 2px; - margin-right: auto; } - -.select2-container--classic.select2-container--open .select2-selection--multiple { - border: 1px solid #5897fb; } - -.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; } - -.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { - border-bottom: none; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; } - -.select2-container--classic .select2-search--dropdown .select2-search__field { - border: 1px solid #aaa; - outline: 0; } - -.select2-container--classic .select2-search--inline .select2-search__field { - outline: 0; - box-shadow: none; } - -.select2-container--classic .select2-dropdown { - background-color: white; - border: 1px solid transparent; } - -.select2-container--classic .select2-dropdown--above { - border-bottom: none; } - -.select2-container--classic .select2-dropdown--below { - border-top: none; } - -.select2-container--classic .select2-results > .select2-results__options { - max-height: 200px; - overflow-y: auto; } - -.select2-container--classic .select2-results__option[role=group] { - padding: 0; } - -.select2-container--classic .select2-results__option[aria-disabled=true] { - color: grey; } - -.select2-container--classic .select2-results__option--highlighted[aria-selected] { - background-color: #3875d7; - color: white; } - -.select2-container--classic .select2-results__group { - cursor: default; - display: block; - padding: 6px; } - -.select2-container--classic.select2-container--open .select2-dropdown { - border-color: #5897fb; } diff --git a/static/admin/css/vendor/select2/select2.min.css b/static/admin/css/vendor/select2/select2.min.css deleted file mode 100644 index 7c18ad5..0000000 --- a/static/admin/css/vendor/select2/select2.min.css +++ /dev/null @@ -1 +0,0 @@ -.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/static/admin/css/widgets.css b/static/admin/css/widgets.css deleted file mode 100644 index 1104e8b..0000000 --- a/static/admin/css/widgets.css +++ /dev/null @@ -1,604 +0,0 @@ -/* SELECTOR (FILTER INTERFACE) */ - -.selector { - width: 800px; - float: left; - display: flex; -} - -.selector select { - width: 380px; - height: 17.2em; - flex: 1 0 auto; -} - -.selector-available, .selector-chosen { - width: 380px; - text-align: center; - margin-bottom: 5px; - display: flex; - flex-direction: column; -} - -.selector-available h2, .selector-chosen h2 { - border: 1px solid var(--border-color); - border-radius: 4px 4px 0 0; -} - -.selector-chosen .list-footer-display { - border: 1px solid var(--border-color); - border-top: none; - border-radius: 0 0 4px 4px; - margin: 0 0 10px; - padding: 8px; - text-align: center; - background: var(--primary); - color: var(--header-link-color); - cursor: pointer; -} -.selector-chosen .list-footer-display__clear { - color: var(--breadcrumbs-fg); -} - -.selector-chosen h2 { - background: var(--primary); - color: var(--header-link-color); -} - -.selector .selector-available h2 { - background: var(--darkened-bg); - color: var(--body-quiet-color); -} - -.selector .selector-filter { - border: 1px solid var(--border-color); - border-width: 0 1px; - padding: 8px; - color: var(--body-quiet-color); - font-size: 0.625rem; - margin: 0; - text-align: left; -} - -.selector .selector-filter label, -.inline-group .aligned .selector .selector-filter label { - float: left; - margin: 7px 0 0; - width: 18px; - height: 18px; - padding: 0; - overflow: hidden; - line-height: 1; - min-width: auto; -} - -.selector .selector-available input, -.selector .selector-chosen input { - width: 320px; - margin-left: 8px; -} - -.selector ul.selector-chooser { - align-self: center; - width: 22px; - background-color: var(--selected-bg); - border-radius: 10px; - margin: 0 5px; - padding: 0; - transform: translateY(-17px); -} - -.selector-chooser li { - margin: 0; - padding: 3px; - list-style-type: none; -} - -.selector select { - padding: 0 10px; - margin: 0 0 10px; - border-radius: 0 0 4px 4px; -} -.selector .selector-chosen--with-filtered select { - margin: 0; - border-radius: 0; - height: 14em; -} - -.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { - display: none; -} - -.selector-add, .selector-remove { - width: 16px; - height: 16px; - display: block; - text-indent: -3000px; - overflow: hidden; - cursor: default; - opacity: 0.55; -} - -.active.selector-add, .active.selector-remove { - opacity: 1; -} - -.active.selector-add:hover, .active.selector-remove:hover { - cursor: pointer; -} - -.selector-add { - background: url(../img/selector-icons.svg) 0 -96px no-repeat; -} - -.active.selector-add:focus, .active.selector-add:hover { - background-position: 0 -112px; -} - -.selector-remove { - background: url(../img/selector-icons.svg) 0 -64px no-repeat; -} - -.active.selector-remove:focus, .active.selector-remove:hover { - background-position: 0 -80px; -} - -a.selector-chooseall, a.selector-clearall { - display: inline-block; - height: 16px; - text-align: left; - margin: 1px auto 3px; - overflow: hidden; - font-weight: bold; - line-height: 16px; - color: var(--body-quiet-color); - text-decoration: none; - opacity: 0.55; -} - -a.active.selector-chooseall:focus, a.active.selector-clearall:focus, -a.active.selector-chooseall:hover, a.active.selector-clearall:hover { - color: var(--link-fg); -} - -a.active.selector-chooseall, a.active.selector-clearall { - opacity: 1; -} - -a.active.selector-chooseall:hover, a.active.selector-clearall:hover { - cursor: pointer; -} - -a.selector-chooseall { - padding: 0 18px 0 0; - background: url(../img/selector-icons.svg) right -160px no-repeat; - cursor: default; -} - -a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { - background-position: 100% -176px; -} - -a.selector-clearall { - padding: 0 0 0 18px; - background: url(../img/selector-icons.svg) 0 -128px no-repeat; - cursor: default; -} - -a.active.selector-clearall:focus, a.active.selector-clearall:hover { - background-position: 0 -144px; -} - -/* STACKED SELECTORS */ - -.stacked { - float: left; - width: 490px; - display: block; -} - -.stacked select { - width: 480px; - height: 10.1em; -} - -.stacked .selector-available, .stacked .selector-chosen { - width: 480px; -} - -.stacked .selector-available { - margin-bottom: 0; -} - -.stacked .selector-available input { - width: 422px; -} - -.stacked ul.selector-chooser { - height: 22px; - width: 50px; - margin: 0 0 10px 40%; - background-color: #eee; - border-radius: 10px; - transform: none; -} - -.stacked .selector-chooser li { - float: left; - padding: 3px 3px 3px 5px; -} - -.stacked .selector-chooseall, .stacked .selector-clearall { - display: none; -} - -.stacked .selector-add { - background: url(../img/selector-icons.svg) 0 -32px no-repeat; - cursor: default; -} - -.stacked .active.selector-add { - background-position: 0 -32px; - cursor: pointer; -} - -.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { - background-position: 0 -48px; - cursor: pointer; -} - -.stacked .selector-remove { - background: url(../img/selector-icons.svg) 0 0 no-repeat; - cursor: default; -} - -.stacked .active.selector-remove { - background-position: 0 0px; - cursor: pointer; -} - -.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { - background-position: 0 -16px; - cursor: pointer; -} - -.selector .help-icon { - background: url(../img/icon-unknown.svg) 0 0 no-repeat; - display: inline-block; - vertical-align: middle; - margin: -2px 0 0 2px; - width: 13px; - height: 13px; -} - -.selector .selector-chosen .help-icon { - background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; -} - -.selector .search-label-icon { - background: url(../img/search.svg) 0 0 no-repeat; - display: inline-block; - height: 1.125rem; - width: 1.125rem; -} - -/* DATE AND TIME */ - -p.datetime { - line-height: 20px; - margin: 0; - padding: 0; - color: var(--body-quiet-color); - font-weight: bold; -} - -.datetime span { - white-space: nowrap; - font-weight: normal; - font-size: 0.6875rem; - color: var(--body-quiet-color); -} - -.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { - margin-left: 5px; - margin-bottom: 4px; -} - -table p.datetime { - font-size: 0.6875rem; - margin-left: 0; - padding-left: 0; -} - -.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { - position: relative; - display: inline-block; - vertical-align: middle; - height: 16px; - width: 16px; - overflow: hidden; -} - -.datetimeshortcuts .clock-icon { - background: url(../img/icon-clock.svg) 0 0 no-repeat; -} - -.datetimeshortcuts a:focus .clock-icon, -.datetimeshortcuts a:hover .clock-icon { - background-position: 0 -16px; -} - -.datetimeshortcuts .date-icon { - background: url(../img/icon-calendar.svg) 0 0 no-repeat; - top: -1px; -} - -.datetimeshortcuts a:focus .date-icon, -.datetimeshortcuts a:hover .date-icon { - background-position: 0 -16px; -} - -.timezonewarning { - font-size: 0.6875rem; - color: var(--body-quiet-color); -} - -/* URL */ - -p.url { - line-height: 20px; - margin: 0; - padding: 0; - color: var(--body-quiet-color); - font-size: 0.6875rem; - font-weight: bold; -} - -.url a { - font-weight: normal; -} - -/* FILE UPLOADS */ - -p.file-upload { - line-height: 20px; - margin: 0; - padding: 0; - color: var(--body-quiet-color); - font-size: 0.6875rem; - font-weight: bold; -} - -.file-upload a { - font-weight: normal; -} - -.file-upload .deletelink { - margin-left: 5px; -} - -span.clearable-file-input label { - color: var(--body-fg); - font-size: 0.6875rem; - display: inline; - float: none; -} - -/* CALENDARS & CLOCKS */ - -.calendarbox, .clockbox { - margin: 5px auto; - font-size: 0.75rem; - width: 19em; - text-align: center; - background: var(--body-bg); - color: var(--body-fg); - border: 1px solid var(--hairline-color); - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); - overflow: hidden; - position: relative; -} - -.clockbox { - width: auto; -} - -.calendar { - margin: 0; - padding: 0; -} - -.calendar table { - margin: 0; - padding: 0; - border-collapse: collapse; - background: white; - width: 100%; -} - -.calendar caption, .calendarbox h2 { - margin: 0; - text-align: center; - border-top: none; - font-weight: 700; - font-size: 0.75rem; - color: #333; - background: var(--accent); -} - -.calendar th { - padding: 8px 5px; - background: var(--darkened-bg); - border-bottom: 1px solid var(--border-color); - font-weight: 400; - font-size: 0.75rem; - text-align: center; - color: var(--body-quiet-color); -} - -.calendar td { - font-weight: 400; - font-size: 0.75rem; - text-align: center; - padding: 0; - border-top: 1px solid var(--hairline-color); - border-bottom: none; -} - -.calendar td.selected a { - background: var(--primary); - color: var(--button-fg); -} - -.calendar td.nonday { - background: var(--darkened-bg); -} - -.calendar td.today a { - font-weight: 700; -} - -.calendar td a, .timelist a { - display: block; - font-weight: 400; - padding: 6px; - text-decoration: none; - color: var(--body-quiet-color); -} - -.calendar td a:focus, .timelist a:focus, -.calendar td a:hover, .timelist a:hover { - background: var(--primary); - color: white; -} - -.calendar td a:active, .timelist a:active { - background: var(--header-bg); - color: white; -} - -.calendarnav { - font-size: 0.625rem; - text-align: center; - color: #ccc; - margin: 0; - padding: 1px 3px; -} - -.calendarnav a:link, #calendarnav a:visited, -#calendarnav a:focus, #calendarnav a:hover { - color: var(--body-quiet-color); -} - -.calendar-shortcuts { - background: var(--body-bg); - color: var(--body-quiet-color); - font-size: 0.6875rem; - line-height: 0.6875rem; - border-top: 1px solid var(--hairline-color); - padding: 8px 0; -} - -.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { - display: block; - position: absolute; - top: 8px; - width: 15px; - height: 15px; - text-indent: -9999px; - padding: 0; -} - -.calendarnav-previous { - left: 10px; - background: url(../img/calendar-icons.svg) 0 0 no-repeat; -} - -.calendarbox .calendarnav-previous:focus, -.calendarbox .calendarnav-previous:hover { - background-position: 0 -15px; -} - -.calendarnav-next { - right: 10px; - background: url(../img/calendar-icons.svg) 0 -30px no-repeat; -} - -.calendarbox .calendarnav-next:focus, -.calendarbox .calendarnav-next:hover { - background-position: 0 -45px; -} - -.calendar-cancel { - margin: 0; - padding: 4px 0; - font-size: 0.75rem; - background: #eee; - border-top: 1px solid var(--border-color); - color: var(--body-fg); -} - -.calendar-cancel:focus, .calendar-cancel:hover { - background: #ddd; -} - -.calendar-cancel a { - color: black; - display: block; -} - -ul.timelist, .timelist li { - list-style-type: none; - margin: 0; - padding: 0; -} - -.timelist a { - padding: 2px; -} - -/* EDIT INLINE */ - -.inline-deletelink { - float: right; - text-indent: -9999px; - background: url(../img/inline-delete.svg) 0 0 no-repeat; - width: 16px; - height: 16px; - border: 0px none; -} - -.inline-deletelink:focus, .inline-deletelink:hover { - cursor: pointer; -} - -/* RELATED WIDGET WRAPPER */ -.related-widget-wrapper { - float: left; /* display properly in form rows with multiple fields */ - overflow: hidden; /* clear floated contents */ -} - -.related-widget-wrapper-link { - opacity: 0.3; -} - -.related-widget-wrapper-link:link { - opacity: .8; -} - -.related-widget-wrapper-link:link:focus, -.related-widget-wrapper-link:link:hover { - opacity: 1; -} - -select + .related-widget-wrapper-link, -.related-widget-wrapper-link + .related-widget-wrapper-link { - margin-left: 7px; -} - -/* GIS MAPS */ -.dj_map { - width: 600px; - height: 400px; -} diff --git a/static/admin/img/LICENSE b/static/admin/img/LICENSE deleted file mode 100644 index a4faaa1..0000000 --- a/static/admin/img/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014 Code Charm Ltd - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/admin/img/README.txt b/static/admin/img/README.txt deleted file mode 100644 index 4eb2e49..0000000 --- a/static/admin/img/README.txt +++ /dev/null @@ -1,7 +0,0 @@ -All icons are taken from Font Awesome (http://fontawesome.io/) project. -The Font Awesome font is licensed under the SIL OFL 1.1: -- https://scripts.sil.org/OFL - -SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG -Font-Awesome-SVG-PNG is licensed under the MIT license (see file license -in current folder). diff --git a/static/admin/img/calendar-icons.svg b/static/admin/img/calendar-icons.svg deleted file mode 100644 index dbf21c3..0000000 --- a/static/admin/img/calendar-icons.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/static/admin/img/gis/move_vertex_off.svg b/static/admin/img/gis/move_vertex_off.svg deleted file mode 100644 index 228854f..0000000 --- a/static/admin/img/gis/move_vertex_off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/admin/img/gis/move_vertex_on.svg b/static/admin/img/gis/move_vertex_on.svg deleted file mode 100644 index 96b87fd..0000000 --- a/static/admin/img/gis/move_vertex_on.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/admin/img/icon-addlink.svg b/static/admin/img/icon-addlink.svg deleted file mode 100644 index e004fb1..0000000 --- a/static/admin/img/icon-addlink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-alert.svg b/static/admin/img/icon-alert.svg deleted file mode 100644 index e51ea83..0000000 --- a/static/admin/img/icon-alert.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-calendar.svg b/static/admin/img/icon-calendar.svg deleted file mode 100644 index 97910a9..0000000 --- a/static/admin/img/icon-calendar.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/static/admin/img/icon-changelink.svg b/static/admin/img/icon-changelink.svg deleted file mode 100644 index bbb137a..0000000 --- a/static/admin/img/icon-changelink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-clock.svg b/static/admin/img/icon-clock.svg deleted file mode 100644 index bf9985d..0000000 --- a/static/admin/img/icon-clock.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/static/admin/img/icon-deletelink.svg b/static/admin/img/icon-deletelink.svg deleted file mode 100644 index 4059b15..0000000 --- a/static/admin/img/icon-deletelink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-no.svg b/static/admin/img/icon-no.svg deleted file mode 100644 index 2e0d383..0000000 --- a/static/admin/img/icon-no.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-unknown-alt.svg b/static/admin/img/icon-unknown-alt.svg deleted file mode 100644 index 1c6b99f..0000000 --- a/static/admin/img/icon-unknown-alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-unknown.svg b/static/admin/img/icon-unknown.svg deleted file mode 100644 index 50b4f97..0000000 --- a/static/admin/img/icon-unknown.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-viewlink.svg b/static/admin/img/icon-viewlink.svg deleted file mode 100644 index a1ca1d3..0000000 --- a/static/admin/img/icon-viewlink.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/icon-yes.svg b/static/admin/img/icon-yes.svg deleted file mode 100644 index 5883d87..0000000 --- a/static/admin/img/icon-yes.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/inline-delete.svg b/static/admin/img/inline-delete.svg deleted file mode 100644 index 17d1ad6..0000000 --- a/static/admin/img/inline-delete.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/search.svg b/static/admin/img/search.svg deleted file mode 100644 index c8c69b2..0000000 --- a/static/admin/img/search.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/selector-icons.svg b/static/admin/img/selector-icons.svg deleted file mode 100644 index 926b8e2..0000000 --- a/static/admin/img/selector-icons.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/static/admin/img/sorting-icons.svg b/static/admin/img/sorting-icons.svg deleted file mode 100644 index 7c31ec9..0000000 --- a/static/admin/img/sorting-icons.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/static/admin/img/tooltag-add.svg b/static/admin/img/tooltag-add.svg deleted file mode 100644 index 1ca64ae..0000000 --- a/static/admin/img/tooltag-add.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/img/tooltag-arrowright.svg b/static/admin/img/tooltag-arrowright.svg deleted file mode 100644 index b664d61..0000000 --- a/static/admin/img/tooltag-arrowright.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/static/admin/js/SelectBox.js b/static/admin/js/SelectBox.js deleted file mode 100644 index 3db4ec7..0000000 --- a/static/admin/js/SelectBox.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; -{ - const SelectBox = { - cache: {}, - init: function(id) { - const box = document.getElementById(id); - SelectBox.cache[id] = []; - const cache = SelectBox.cache[id]; - for (const node of box.options) { - cache.push({value: node.value, text: node.text, displayed: 1}); - } - }, - redisplay: function(id) { - // Repopulate HTML select box from cache - const box = document.getElementById(id); - const scroll_value_from_top = box.scrollTop; - box.innerHTML = ''; - for (const node of SelectBox.cache[id]) { - if (node.displayed) { - const new_option = new Option(node.text, node.value, false, false); - // Shows a tooltip when hovering over the option - new_option.title = node.text; - box.appendChild(new_option); - } - } - box.scrollTop = scroll_value_from_top; - }, - filter: function(id, text) { - // Redisplay the HTML select box, displaying only the choices containing ALL - // the words in text. (It's an AND search.) - const tokens = text.toLowerCase().split(/\s+/); - for (const node of SelectBox.cache[id]) { - node.displayed = 1; - const node_text = node.text.toLowerCase(); - for (const token of tokens) { - if (!node_text.includes(token)) { - node.displayed = 0; - break; // Once the first token isn't found we're done - } - } - } - SelectBox.redisplay(id); - }, - get_hidden_node_count(id) { - const cache = SelectBox.cache[id] || []; - return cache.filter(node => node.displayed === 0).length; - }, - delete_from_cache: function(id, value) { - let delete_index = null; - const cache = SelectBox.cache[id]; - for (const [i, node] of cache.entries()) { - if (node.value === value) { - delete_index = i; - break; - } - } - cache.splice(delete_index, 1); - }, - add_to_cache: function(id, option) { - SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); - }, - cache_contains: function(id, value) { - // Check if an item is contained in the cache - for (const node of SelectBox.cache[id]) { - if (node.value === value) { - return true; - } - } - return false; - }, - move: function(from, to) { - const from_box = document.getElementById(from); - for (const option of from_box.options) { - const option_value = option.value; - if (option.selected && SelectBox.cache_contains(from, option_value)) { - SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); - SelectBox.delete_from_cache(from, option_value); - } - } - SelectBox.redisplay(from); - SelectBox.redisplay(to); - }, - move_all: function(from, to) { - const from_box = document.getElementById(from); - for (const option of from_box.options) { - const option_value = option.value; - if (SelectBox.cache_contains(from, option_value)) { - SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); - SelectBox.delete_from_cache(from, option_value); - } - } - SelectBox.redisplay(from); - SelectBox.redisplay(to); - }, - sort: function(id) { - SelectBox.cache[id].sort(function(a, b) { - a = a.text.toLowerCase(); - b = b.text.toLowerCase(); - if (a > b) { - return 1; - } - if (a < b) { - return -1; - } - return 0; - } ); - }, - select_all: function(id) { - const box = document.getElementById(id); - for (const option of box.options) { - option.selected = true; - } - } - }; - window.SelectBox = SelectBox; -} diff --git a/static/admin/js/SelectFilter2.js b/static/admin/js/SelectFilter2.js deleted file mode 100644 index 9a4e0a3..0000000 --- a/static/admin/js/SelectFilter2.js +++ /dev/null @@ -1,283 +0,0 @@ -/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ -/* -SelectFilter2 - Turns a multiple-select box into a filter interface. - -Requires core.js and SelectBox.js. -*/ -'use strict'; -{ - window.SelectFilter = { - init: function(field_id, field_name, is_stacked) { - if (field_id.match(/__prefix__/)) { - // Don't initialize on empty forms. - return; - } - const from_box = document.getElementById(field_id); - from_box.id += '_from'; // change its ID - from_box.className = 'filtered'; - - for (const p of from_box.parentNode.getElementsByTagName('p')) { - if (p.classList.contains("info")) { - // Remove

, because it just gets in the way. - from_box.parentNode.removeChild(p); - } else if (p.classList.contains("help")) { - // Move help text up to the top so it isn't below the select - // boxes or wrapped off on the side to the right of the add - // button: - from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); - } - } - - //

or
- const selector_div = quickElement('div', from_box.parentNode); - selector_div.className = is_stacked ? 'selector stacked' : 'selector'; - - //
- const selector_available = quickElement('div', selector_div); - selector_available.className = 'selector-available'; - const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); - quickElement( - 'span', title_available, '', - 'class', 'help help-tooltip help-icon', - 'title', interpolate( - gettext( - 'This is the list of available %s. You may choose some by ' + - 'selecting them in the box below and then clicking the ' + - '"Choose" arrow between the two boxes.' - ), - [field_name] - ) - ); - - const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); - filter_p.className = 'selector-filter'; - - const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); - - quickElement( - 'span', search_filter_label, '', - 'class', 'help-tooltip search-label-icon', - 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) - ); - - filter_p.appendChild(document.createTextNode(' ')); - - const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); - filter_input.id = field_id + '_input'; - - selector_available.appendChild(from_box); - const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); - choose_all.className = 'selector-chooseall'; - - //
    - const selector_chooser = quickElement('ul', selector_div); - selector_chooser.className = 'selector-chooser'; - const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); - add_link.className = 'selector-add'; - const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); - remove_link.className = 'selector-remove'; - - //
    - const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); - selector_chosen.className = 'selector-chosen'; - const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); - quickElement( - 'span', title_chosen, '', - 'class', 'help help-tooltip help-icon', - 'title', interpolate( - gettext( - 'This is the list of chosen %s. You may remove some by ' + - 'selecting them in the box below and then clicking the ' + - '"Remove" arrow between the two boxes.' - ), - [field_name] - ) - ); - - const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); - filter_selected_p.className = 'selector-filter'; - - const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); - - quickElement( - 'span', search_filter_selected_label, '', - 'class', 'help-tooltip search-label-icon', - 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) - ); - - filter_selected_p.appendChild(document.createTextNode(' ')); - - const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); - filter_selected_input.id = field_id + '_selected_input'; - - const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); - to_box.className = 'filtered'; - - const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); - quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); - quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); - - const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); - clear_all.className = 'selector-clearall'; - - from_box.name = from_box.name + '_old'; - - // Set up the JavaScript event handlers for the select box filter interface - const move_selection = function(e, elem, move_func, from, to) { - if (elem.classList.contains('active')) { - move_func(from, to); - SelectFilter.refresh_icons(field_id); - SelectFilter.refresh_filtered_selects(field_id); - SelectFilter.refresh_filtered_warning(field_id); - } - e.preventDefault(); - }; - choose_all.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); - }); - add_link.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); - }); - remove_link.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); - }); - clear_all.addEventListener('click', function(e) { - move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); - }); - warning_footer.addEventListener('click', function(e) { - filter_selected_input.value = ''; - SelectBox.filter(field_id + '_to', ''); - SelectFilter.refresh_filtered_warning(field_id); - SelectFilter.refresh_icons(field_id); - }); - filter_input.addEventListener('keypress', function(e) { - SelectFilter.filter_key_press(e, field_id, '_from', '_to'); - }); - filter_input.addEventListener('keyup', function(e) { - SelectFilter.filter_key_up(e, field_id, '_from'); - }); - filter_input.addEventListener('keydown', function(e) { - SelectFilter.filter_key_down(e, field_id, '_from', '_to'); - }); - filter_selected_input.addEventListener('keypress', function(e) { - SelectFilter.filter_key_press(e, field_id, '_to', '_from'); - }); - filter_selected_input.addEventListener('keyup', function(e) { - SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); - }); - filter_selected_input.addEventListener('keydown', function(e) { - SelectFilter.filter_key_down(e, field_id, '_to', '_from'); - }); - selector_div.addEventListener('change', function(e) { - if (e.target.tagName === 'SELECT') { - SelectFilter.refresh_icons(field_id); - } - }); - selector_div.addEventListener('dblclick', function(e) { - if (e.target.tagName === 'OPTION') { - if (e.target.closest('select').id === field_id + '_to') { - SelectBox.move(field_id + '_to', field_id + '_from'); - } else { - SelectBox.move(field_id + '_from', field_id + '_to'); - } - SelectFilter.refresh_icons(field_id); - } - }); - from_box.closest('form').addEventListener('submit', function() { - SelectBox.filter(field_id + '_to', ''); - SelectBox.select_all(field_id + '_to'); - }); - SelectBox.init(field_id + '_from'); - SelectBox.init(field_id + '_to'); - // Move selected from_box options to to_box - SelectBox.move(field_id + '_from', field_id + '_to'); - - // Initial icon refresh - SelectFilter.refresh_icons(field_id); - }, - any_selected: function(field) { - // Temporarily add the required attribute and check validity. - field.required = true; - const any_selected = field.checkValidity(); - field.required = false; - return any_selected; - }, - refresh_filtered_warning: function(field_id) { - const count = SelectBox.get_hidden_node_count(field_id + '_to'); - const selector = document.getElementById(field_id + '_selector_chosen'); - const warning = document.getElementById(field_id + '_list-footer-display-text'); - selector.className = selector.className.replace('selector-chosen--with-filtered', ''); - warning.textContent = interpolate(ngettext( - '%s selected option not visible', - '%s selected options not visible', - count - ), [count]); - if(count > 0) { - selector.className += ' selector-chosen--with-filtered'; - } - }, - refresh_filtered_selects: function(field_id) { - SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); - SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); - }, - refresh_icons: function(field_id) { - const from = document.getElementById(field_id + '_from'); - const to = document.getElementById(field_id + '_to'); - // Active if at least one item is selected - document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); - document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); - // Active if the corresponding box isn't empty - document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); - document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); - SelectFilter.refresh_filtered_warning(field_id); - }, - filter_key_press: function(event, field_id, source, target) { - const source_box = document.getElementById(field_id + source); - // don't submit form if user pressed Enter - if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { - source_box.selectedIndex = 0; - SelectBox.move(field_id + source, field_id + target); - source_box.selectedIndex = 0; - event.preventDefault(); - } - }, - filter_key_up: function(event, field_id, source, filter_input) { - const input = filter_input || '_input'; - const source_box = document.getElementById(field_id + source); - const temp = source_box.selectedIndex; - SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); - source_box.selectedIndex = temp; - SelectFilter.refresh_filtered_warning(field_id); - SelectFilter.refresh_icons(field_id); - }, - filter_key_down: function(event, field_id, source, target) { - const source_box = document.getElementById(field_id + source); - // right key (39) or left key (37) - const direction = source === '_from' ? 39 : 37; - // right arrow -- move across - if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { - const old_index = source_box.selectedIndex; - SelectBox.move(field_id + source, field_id + target); - SelectFilter.refresh_filtered_selects(field_id); - SelectFilter.refresh_filtered_warning(field_id); - source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; - return; - } - // down arrow -- wrap around - if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { - source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; - } - // up arrow -- wrap around - if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { - source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; - } - } - }; - - window.addEventListener('load', function(e) { - document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { - const data = el.dataset; - SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); - }); - }); -} diff --git a/static/admin/js/actions.js b/static/admin/js/actions.js deleted file mode 100644 index 20a5c14..0000000 --- a/static/admin/js/actions.js +++ /dev/null @@ -1,201 +0,0 @@ -/*global gettext, interpolate, ngettext*/ -'use strict'; -{ - function show(selector) { - document.querySelectorAll(selector).forEach(function(el) { - el.classList.remove('hidden'); - }); - } - - function hide(selector) { - document.querySelectorAll(selector).forEach(function(el) { - el.classList.add('hidden'); - }); - } - - function showQuestion(options) { - hide(options.acrossClears); - show(options.acrossQuestions); - hide(options.allContainer); - } - - function showClear(options) { - show(options.acrossClears); - hide(options.acrossQuestions); - document.querySelector(options.actionContainer).classList.remove(options.selectedClass); - show(options.allContainer); - hide(options.counterContainer); - } - - function reset(options) { - hide(options.acrossClears); - hide(options.acrossQuestions); - hide(options.allContainer); - show(options.counterContainer); - } - - function clearAcross(options) { - reset(options); - const acrossInputs = document.querySelectorAll(options.acrossInput); - acrossInputs.forEach(function(acrossInput) { - acrossInput.value = 0; - }); - document.querySelector(options.actionContainer).classList.remove(options.selectedClass); - } - - function checker(actionCheckboxes, options, checked) { - if (checked) { - showQuestion(options); - } else { - reset(options); - } - actionCheckboxes.forEach(function(el) { - el.checked = checked; - el.closest('tr').classList.toggle(options.selectedClass, checked); - }); - } - - function updateCounter(actionCheckboxes, options) { - const sel = Array.from(actionCheckboxes).filter(function(el) { - return el.checked; - }).length; - const counter = document.querySelector(options.counterContainer); - // data-actions-icnt is defined in the generated HTML - // and contains the total amount of objects in the queryset - const actions_icnt = Number(counter.dataset.actionsIcnt); - counter.textContent = interpolate( - ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { - sel: sel, - cnt: actions_icnt - }, true); - const allToggle = document.getElementById(options.allToggleId); - allToggle.checked = sel === actionCheckboxes.length; - if (allToggle.checked) { - showQuestion(options); - } else { - clearAcross(options); - } - } - - const defaults = { - actionContainer: "div.actions", - counterContainer: "span.action-counter", - allContainer: "div.actions span.all", - acrossInput: "div.actions input.select-across", - acrossQuestions: "div.actions span.question", - acrossClears: "div.actions span.clear", - allToggleId: "action-toggle", - selectedClass: "selected" - }; - - window.Actions = function(actionCheckboxes, options) { - options = Object.assign({}, defaults, options); - let list_editable_changed = false; - let lastChecked = null; - let shiftPressed = false; - - document.addEventListener('keydown', (event) => { - shiftPressed = event.shiftKey; - }); - - document.addEventListener('keyup', (event) => { - shiftPressed = event.shiftKey; - }); - - document.getElementById(options.allToggleId).addEventListener('click', function(event) { - checker(actionCheckboxes, options, this.checked); - updateCounter(actionCheckboxes, options); - }); - - document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { - el.addEventListener('click', function(event) { - event.preventDefault(); - const acrossInputs = document.querySelectorAll(options.acrossInput); - acrossInputs.forEach(function(acrossInput) { - acrossInput.value = 1; - }); - showClear(options); - }); - }); - - document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { - el.addEventListener('click', function(event) { - event.preventDefault(); - document.getElementById(options.allToggleId).checked = false; - clearAcross(options); - checker(actionCheckboxes, options, false); - updateCounter(actionCheckboxes, options); - }); - }); - - function affectedCheckboxes(target, withModifier) { - const multiSelect = (lastChecked && withModifier && lastChecked !== target); - if (!multiSelect) { - return [target]; - } - const checkboxes = Array.from(actionCheckboxes); - const targetIndex = checkboxes.findIndex(el => el === target); - const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); - const startIndex = Math.min(targetIndex, lastCheckedIndex); - const endIndex = Math.max(targetIndex, lastCheckedIndex); - const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); - return filtered; - }; - - Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { - el.addEventListener('change', function(event) { - const target = event.target; - if (target.classList.contains('action-select')) { - const checkboxes = affectedCheckboxes(target, shiftPressed); - checker(checkboxes, options, target.checked); - updateCounter(actionCheckboxes, options); - lastChecked = target; - } else { - list_editable_changed = true; - } - }); - }); - - document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { - if (list_editable_changed) { - const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); - if (!confirmed) { - event.preventDefault(); - } - } - }); - - const el = document.querySelector('#changelist-form input[name=_save]'); - // The button does not exist if no fields are editable. - if (el) { - el.addEventListener('click', function(event) { - if (document.querySelector('[name=action]').value) { - const text = list_editable_changed - ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") - : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); - if (!confirm(text)) { - event.preventDefault(); - } - } - }); - } - }; - - // Call function fn when the DOM is loaded and ready. If it is already - // loaded, call the function now. - // http://youmightnotneedjquery.com/#ready - function ready(fn) { - if (document.readyState !== 'loading') { - fn(); - } else { - document.addEventListener('DOMContentLoaded', fn); - } - } - - ready(function() { - const actionsEls = document.querySelectorAll('tr input.action-select'); - if (actionsEls.length > 0) { - Actions(actionsEls); - } - }); -} diff --git a/static/admin/js/admin/DateTimeShortcuts.js b/static/admin/js/admin/DateTimeShortcuts.js deleted file mode 100644 index aa1cae9..0000000 --- a/static/admin/js/admin/DateTimeShortcuts.js +++ /dev/null @@ -1,408 +0,0 @@ -/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ -// Inserts shortcut buttons after all of the following: -// -// -'use strict'; -{ - const DateTimeShortcuts = { - calendars: [], - calendarInputs: [], - clockInputs: [], - clockHours: { - default_: [ - [gettext_noop('Now'), -1], - [gettext_noop('Midnight'), 0], - [gettext_noop('6 a.m.'), 6], - [gettext_noop('Noon'), 12], - [gettext_noop('6 p.m.'), 18] - ] - }, - dismissClockFunc: [], - dismissCalendarFunc: [], - calendarDivName1: 'calendarbox', // name of calendar
    that gets toggled - calendarDivName2: 'calendarin', // name of
    that contains calendar - calendarLinkName: 'calendarlink', // name of the link that is used to toggle - clockDivName: 'clockbox', // name of clock
    that gets toggled - clockLinkName: 'clocklink', // name of the link that is used to toggle - shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts - timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch - timezoneOffset: 0, - init: function() { - const serverOffset = document.body.dataset.adminUtcOffset; - if (serverOffset) { - const localOffset = new Date().getTimezoneOffset() * -60; - DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; - } - - for (const inp of document.getElementsByTagName('input')) { - if (inp.type === 'text' && inp.classList.contains('vTimeField')) { - DateTimeShortcuts.addClock(inp); - DateTimeShortcuts.addTimezoneWarning(inp); - } - else if (inp.type === 'text' && inp.classList.contains('vDateField')) { - DateTimeShortcuts.addCalendar(inp); - DateTimeShortcuts.addTimezoneWarning(inp); - } - } - }, - // Return the current time while accounting for the server timezone. - now: function() { - const serverOffset = document.body.dataset.adminUtcOffset; - if (serverOffset) { - const localNow = new Date(); - const localOffset = localNow.getTimezoneOffset() * -60; - localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); - return localNow; - } else { - return new Date(); - } - }, - // Add a warning when the time zone in the browser and backend do not match. - addTimezoneWarning: function(inp) { - const warningClass = DateTimeShortcuts.timezoneWarningClass; - let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; - - // Only warn if there is a time zone mismatch. - if (!timezoneOffset) { - return; - } - - // Check if warning is already there. - if (inp.parentNode.querySelectorAll('.' + warningClass).length) { - return; - } - - let message; - if (timezoneOffset > 0) { - message = ngettext( - 'Note: You are %s hour ahead of server time.', - 'Note: You are %s hours ahead of server time.', - timezoneOffset - ); - } - else { - timezoneOffset *= -1; - message = ngettext( - 'Note: You are %s hour behind server time.', - 'Note: You are %s hours behind server time.', - timezoneOffset - ); - } - message = interpolate(message, [timezoneOffset]); - - const warning = document.createElement('div'); - warning.classList.add('help', warningClass); - warning.textContent = message; - inp.parentNode.appendChild(warning); - }, - // Add clock widget to a given field - addClock: function(inp) { - const num = DateTimeShortcuts.clockInputs.length; - DateTimeShortcuts.clockInputs[num] = inp; - DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; - - // Shortcut links (clock icon and "Now" link) - const shortcuts_span = document.createElement('span'); - shortcuts_span.className = DateTimeShortcuts.shortCutsClass; - inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); - const now_link = document.createElement('a'); - now_link.href = "#"; - now_link.textContent = gettext('Now'); - now_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleClockQuicklink(num, -1); - }); - const clock_link = document.createElement('a'); - clock_link.href = '#'; - clock_link.id = DateTimeShortcuts.clockLinkName + num; - clock_link.addEventListener('click', function(e) { - e.preventDefault(); - // avoid triggering the document click handler to dismiss the clock - e.stopPropagation(); - DateTimeShortcuts.openClock(num); - }); - - quickElement( - 'span', clock_link, '', - 'class', 'clock-icon', - 'title', gettext('Choose a Time') - ); - shortcuts_span.appendChild(document.createTextNode('\u00A0')); - shortcuts_span.appendChild(now_link); - shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); - shortcuts_span.appendChild(clock_link); - - // Create clock link div - // - // Markup looks like: - //
    - //

    Choose a time

    - // - //

    Cancel

    - //
    - - const clock_box = document.createElement('div'); - clock_box.style.display = 'none'; - clock_box.style.position = 'absolute'; - clock_box.className = 'clockbox module'; - clock_box.id = DateTimeShortcuts.clockDivName + num; - document.body.appendChild(clock_box); - clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); - - quickElement('h2', clock_box, gettext('Choose a time')); - const time_list = quickElement('ul', clock_box); - time_list.className = 'timelist'; - // The list of choices can be overridden in JavaScript like this: - // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; - // where name is the name attribute of the . - const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; - DateTimeShortcuts.clockHours[name].forEach(function(element) { - const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); - time_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleClockQuicklink(num, element[1]); - }); - }); - - const cancel_p = quickElement('p', clock_box); - cancel_p.className = 'calendar-cancel'; - const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); - cancel_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.dismissClock(num); - }); - - document.addEventListener('keyup', function(event) { - if (event.which === 27) { - // ESC key closes popup - DateTimeShortcuts.dismissClock(num); - event.preventDefault(); - } - }); - }, - openClock: function(num) { - const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); - const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); - - // Recalculate the clockbox position - // is it left-to-right or right-to-left layout ? - if (window.getComputedStyle(document.body).direction !== 'rtl') { - clock_box.style.left = findPosX(clock_link) + 17 + 'px'; - } - else { - // since style's width is in em, it'd be tough to calculate - // px value of it. let's use an estimated px for now - clock_box.style.left = findPosX(clock_link) - 110 + 'px'; - } - clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; - - // Show the clock box - clock_box.style.display = 'block'; - document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); - }, - dismissClock: function(num) { - document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; - document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); - }, - handleClockQuicklink: function(num, val) { - let d; - if (val === -1) { - d = DateTimeShortcuts.now(); - } - else { - d = new Date(1970, 1, 1, val, 0, 0, 0); - } - DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); - DateTimeShortcuts.clockInputs[num].focus(); - DateTimeShortcuts.dismissClock(num); - }, - // Add calendar widget to a given field. - addCalendar: function(inp) { - const num = DateTimeShortcuts.calendars.length; - - DateTimeShortcuts.calendarInputs[num] = inp; - DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; - - // Shortcut links (calendar icon and "Today" link) - const shortcuts_span = document.createElement('span'); - shortcuts_span.className = DateTimeShortcuts.shortCutsClass; - inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); - const today_link = document.createElement('a'); - today_link.href = '#'; - today_link.appendChild(document.createTextNode(gettext('Today'))); - today_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, 0); - }); - const cal_link = document.createElement('a'); - cal_link.href = '#'; - cal_link.id = DateTimeShortcuts.calendarLinkName + num; - cal_link.addEventListener('click', function(e) { - e.preventDefault(); - // avoid triggering the document click handler to dismiss the calendar - e.stopPropagation(); - DateTimeShortcuts.openCalendar(num); - }); - quickElement( - 'span', cal_link, '', - 'class', 'date-icon', - 'title', gettext('Choose a Date') - ); - shortcuts_span.appendChild(document.createTextNode('\u00A0')); - shortcuts_span.appendChild(today_link); - shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); - shortcuts_span.appendChild(cal_link); - - // Create calendarbox div. - // - // Markup looks like: - // - //
    - //

    - // - // February 2003 - //

    - //
    - // - //
    - //
    - // Yesterday | Today | Tomorrow - //
    - //

    Cancel

    - //
    - const cal_box = document.createElement('div'); - cal_box.style.display = 'none'; - cal_box.style.position = 'absolute'; - cal_box.className = 'calendarbox module'; - cal_box.id = DateTimeShortcuts.calendarDivName1 + num; - document.body.appendChild(cal_box); - cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); - - // next-prev links - const cal_nav = quickElement('div', cal_box); - const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); - cal_nav_prev.className = 'calendarnav-previous'; - cal_nav_prev.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.drawPrev(num); - }); - - const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); - cal_nav_next.className = 'calendarnav-next'; - cal_nav_next.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.drawNext(num); - }); - - // main box - const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); - cal_main.className = 'calendar'; - DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); - DateTimeShortcuts.calendars[num].drawCurrent(); - - // calendar shortcuts - const shortcuts = quickElement('div', cal_box); - shortcuts.className = 'calendar-shortcuts'; - let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); - day_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, -1); - }); - shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); - day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); - day_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, 0); - }); - shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); - day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); - day_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.handleCalendarQuickLink(num, +1); - }); - - // cancel bar - const cancel_p = quickElement('p', cal_box); - cancel_p.className = 'calendar-cancel'; - const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); - cancel_link.addEventListener('click', function(e) { - e.preventDefault(); - DateTimeShortcuts.dismissCalendar(num); - }); - document.addEventListener('keyup', function(event) { - if (event.which === 27) { - // ESC key closes popup - DateTimeShortcuts.dismissCalendar(num); - event.preventDefault(); - } - }); - }, - openCalendar: function(num) { - const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); - const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); - const inp = DateTimeShortcuts.calendarInputs[num]; - - // Determine if the current value in the input has a valid date. - // If so, draw the calendar with that date's year and month. - if (inp.value) { - const format = get_format('DATE_INPUT_FORMATS')[0]; - const selected = inp.value.strptime(format); - const year = selected.getUTCFullYear(); - const month = selected.getUTCMonth() + 1; - const re = /\d{4}/; - if (re.test(year.toString()) && month >= 1 && month <= 12) { - DateTimeShortcuts.calendars[num].drawDate(month, year, selected); - } - } - - // Recalculate the clockbox position - // is it left-to-right or right-to-left layout ? - if (window.getComputedStyle(document.body).direction !== 'rtl') { - cal_box.style.left = findPosX(cal_link) + 17 + 'px'; - } - else { - // since style's width is in em, it'd be tough to calculate - // px value of it. let's use an estimated px for now - cal_box.style.left = findPosX(cal_link) - 180 + 'px'; - } - cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; - - cal_box.style.display = 'block'; - document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); - }, - dismissCalendar: function(num) { - document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; - document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); - }, - drawPrev: function(num) { - DateTimeShortcuts.calendars[num].drawPreviousMonth(); - }, - drawNext: function(num) { - DateTimeShortcuts.calendars[num].drawNextMonth(); - }, - handleCalendarCallback: function(num) { - const format = get_format('DATE_INPUT_FORMATS')[0]; - return function(y, m, d) { - DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); - DateTimeShortcuts.calendarInputs[num].focus(); - document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; - }; - }, - handleCalendarQuickLink: function(num, offset) { - const d = DateTimeShortcuts.now(); - d.setDate(d.getDate() + offset); - DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); - DateTimeShortcuts.calendarInputs[num].focus(); - DateTimeShortcuts.dismissCalendar(num); - } - }; - - window.addEventListener('load', DateTimeShortcuts.init); - window.DateTimeShortcuts = DateTimeShortcuts; -} diff --git a/static/admin/js/admin/RelatedObjectLookups.js b/static/admin/js/admin/RelatedObjectLookups.js deleted file mode 100644 index afb6b66..0000000 --- a/static/admin/js/admin/RelatedObjectLookups.js +++ /dev/null @@ -1,238 +0,0 @@ -/*global SelectBox, interpolate*/ -// Handles related-objects functionality: lookup link for raw_id_fields -// and Add Another links. -'use strict'; -{ - const $ = django.jQuery; - let popupIndex = 0; - const relatedWindows = []; - - function dismissChildPopups() { - relatedWindows.forEach(function(win) { - if(!win.closed) { - win.dismissChildPopups(); - win.close(); - } - }); - } - - function setPopupIndex() { - if(document.getElementsByName("_popup").length > 0) { - const index = window.name.lastIndexOf("__") + 2; - popupIndex = parseInt(window.name.substring(index)); - } else { - popupIndex = 0; - } - } - - function addPopupIndex(name) { - return name + "__" + (popupIndex + 1); - } - - function removePopupIndex(name) { - return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); - } - - function showAdminPopup(triggeringLink, name_regexp, add_popup) { - const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); - const href = new URL(triggeringLink.href); - if (add_popup) { - href.searchParams.set('_popup', 1); - } - const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); - relatedWindows.push(win); - win.focus(); - return false; - } - - function showRelatedObjectLookupPopup(triggeringLink) { - return showAdminPopup(triggeringLink, /^lookup_/, true); - } - - function dismissRelatedLookupPopup(win, chosenId) { - const name = removePopupIndex(win.name); - const elem = document.getElementById(name); - if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { - elem.value += ',' + chosenId; - } else { - document.getElementById(name).value = chosenId; - } - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - function showRelatedObjectPopup(triggeringLink) { - return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); - } - - function updateRelatedObjectLinks(triggeringLink) { - const $this = $(triggeringLink); - const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); - if (!siblings.length) { - return; - } - const value = $this.val(); - if (value) { - siblings.each(function() { - const elm = $(this); - elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); - }); - } else { - siblings.removeAttr('href'); - } - } - - function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { - // After create/edit a model from the options next to the current - // select (+ or :pencil:) update ForeignKey PK of the rest of selects - // in the page. - - const path = win.location.pathname; - // Extract the model from the popup url '...//add/' or - // '...///change/' depending the action (add or change). - const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; - // Exclude autocomplete selects. - const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); - - selectsRelated.forEach(function(select) { - if (currentSelect === select) { - return; - } - - let option = select.querySelector(`option[value="${objId}"]`); - - if (!option) { - option = new Option(newRepr, newId); - select.options.add(option); - return; - } - - option.textContent = newRepr; - option.value = newId; - }); - } - - function dismissAddRelatedObjectPopup(win, newId, newRepr) { - const name = removePopupIndex(win.name); - const elem = document.getElementById(name); - if (elem) { - const elemName = elem.nodeName.toUpperCase(); - if (elemName === 'SELECT') { - elem.options[elem.options.length] = new Option(newRepr, newId, true, true); - updateRelatedSelectsOptions(elem, win, null, newRepr, newId); - } else if (elemName === 'INPUT') { - if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { - elem.value += ',' + newId; - } else { - elem.value = newId; - } - } - // Trigger a change event to update related links if required. - $(elem).trigger('change'); - } else { - const toId = name + "_to"; - const o = new Option(newRepr, newId); - SelectBox.add_to_cache(toId, o); - SelectBox.redisplay(toId); - } - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { - const id = removePopupIndex(win.name.replace(/^edit_/, '')); - const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); - const selects = $(selectsSelector); - selects.find('option').each(function() { - if (this.value === objId) { - this.textContent = newRepr; - this.value = newId; - } - }).trigger('change'); - updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); - selects.next().find('.select2-selection__rendered').each(function() { - // The element can have a clear button as a child. - // Use the lastChild to modify only the displayed value. - this.lastChild.textContent = newRepr; - this.title = newRepr; - }); - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - function dismissDeleteRelatedObjectPopup(win, objId) { - const id = removePopupIndex(win.name.replace(/^delete_/, '')); - const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); - const selects = $(selectsSelector); - selects.find('option').each(function() { - if (this.value === objId) { - $(this).remove(); - } - }).trigger('change'); - const index = relatedWindows.indexOf(win); - if (index > -1) { - relatedWindows.splice(index, 1); - } - win.close(); - } - - window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; - window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; - window.showRelatedObjectPopup = showRelatedObjectPopup; - window.updateRelatedObjectLinks = updateRelatedObjectLinks; - window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; - window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; - window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; - window.dismissChildPopups = dismissChildPopups; - - // Kept for backward compatibility - window.showAddAnotherPopup = showRelatedObjectPopup; - window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; - - window.addEventListener('unload', function(evt) { - window.dismissChildPopups(); - }); - - $(document).ready(function() { - setPopupIndex(); - $("a[data-popup-opener]").on('click', function(event) { - event.preventDefault(); - opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); - }); - $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { - e.preventDefault(); - if (this.href) { - const event = $.Event('django:show-related', {href: this.href}); - $(this).trigger(event); - if (!event.isDefaultPrevented()) { - showRelatedObjectPopup(this); - } - } - }); - $('body').on('change', '.related-widget-wrapper select', function(e) { - const event = $.Event('django:update-related'); - $(this).trigger(event); - if (!event.isDefaultPrevented()) { - updateRelatedObjectLinks(this); - } - }); - $('.related-widget-wrapper select').trigger('change'); - $('body').on('click', '.related-lookup', function(e) { - e.preventDefault(); - const event = $.Event('django:lookup-related'); - $(this).trigger(event); - if (!event.isDefaultPrevented()) { - showRelatedObjectLookupPopup(this); - } - }); - }); -} diff --git a/static/admin/js/autocomplete.js b/static/admin/js/autocomplete.js deleted file mode 100644 index d3daeab..0000000 --- a/static/admin/js/autocomplete.js +++ /dev/null @@ -1,33 +0,0 @@ -'use strict'; -{ - const $ = django.jQuery; - - $.fn.djangoAdminSelect2 = function() { - $.each(this, function(i, element) { - $(element).select2({ - ajax: { - data: (params) => { - return { - term: params.term, - page: params.page, - app_label: element.dataset.appLabel, - model_name: element.dataset.modelName, - field_name: element.dataset.fieldName - }; - } - } - }); - }); - return this; - }; - - $(function() { - // Initialize all autocomplete widgets except the one in the template - // form used when a new formset is added. - $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); - }); - - document.addEventListener('formset:added', (event) => { - $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); - }); -} diff --git a/static/admin/js/calendar.js b/static/admin/js/calendar.js deleted file mode 100644 index a62d10a..0000000 --- a/static/admin/js/calendar.js +++ /dev/null @@ -1,221 +0,0 @@ -/*global gettext, pgettext, get_format, quickElement, removeChildren*/ -/* -calendar.js - Calendar functions by Adrian Holovaty -depends on core.js for utility functions like removeChildren or quickElement -*/ -'use strict'; -{ - // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions - const CalendarNamespace = { - monthsOfYear: [ - gettext('January'), - gettext('February'), - gettext('March'), - gettext('April'), - gettext('May'), - gettext('June'), - gettext('July'), - gettext('August'), - gettext('September'), - gettext('October'), - gettext('November'), - gettext('December') - ], - monthsOfYearAbbrev: [ - pgettext('abbrev. month January', 'Jan'), - pgettext('abbrev. month February', 'Feb'), - pgettext('abbrev. month March', 'Mar'), - pgettext('abbrev. month April', 'Apr'), - pgettext('abbrev. month May', 'May'), - pgettext('abbrev. month June', 'Jun'), - pgettext('abbrev. month July', 'Jul'), - pgettext('abbrev. month August', 'Aug'), - pgettext('abbrev. month September', 'Sep'), - pgettext('abbrev. month October', 'Oct'), - pgettext('abbrev. month November', 'Nov'), - pgettext('abbrev. month December', 'Dec') - ], - daysOfWeek: [ - pgettext('one letter Sunday', 'S'), - pgettext('one letter Monday', 'M'), - pgettext('one letter Tuesday', 'T'), - pgettext('one letter Wednesday', 'W'), - pgettext('one letter Thursday', 'T'), - pgettext('one letter Friday', 'F'), - pgettext('one letter Saturday', 'S') - ], - firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), - isLeapYear: function(year) { - return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); - }, - getDaysInMonth: function(month, year) { - let days; - if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { - days = 31; - } - else if (month === 4 || month === 6 || month === 9 || month === 11) { - days = 30; - } - else if (month === 2 && CalendarNamespace.isLeapYear(year)) { - days = 29; - } - else { - days = 28; - } - return days; - }, - draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 - const today = new Date(); - const todayDay = today.getDate(); - const todayMonth = today.getMonth() + 1; - const todayYear = today.getFullYear(); - let todayClass = ''; - - // Use UTC functions here because the date field does not contain time - // and using the UTC function variants prevent the local time offset - // from altering the date, specifically the day field. For example: - // - // ``` - // var x = new Date('2013-10-02'); - // var day = x.getDate(); - // ``` - // - // The day variable above will be 1 instead of 2 in, say, US Pacific time - // zone. - let isSelectedMonth = false; - if (typeof selected !== 'undefined') { - isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); - } - - month = parseInt(month); - year = parseInt(year); - const calDiv = document.getElementById(div_id); - removeChildren(calDiv); - const calTable = document.createElement('table'); - quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); - const tableBody = quickElement('tbody', calTable); - - // Draw days-of-week header - let tableRow = quickElement('tr', tableBody); - for (let i = 0; i < 7; i++) { - quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); - } - - const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); - const days = CalendarNamespace.getDaysInMonth(month, year); - - let nonDayCell; - - // Draw blanks before first of month - tableRow = quickElement('tr', tableBody); - for (let i = 0; i < startingPos; i++) { - nonDayCell = quickElement('td', tableRow, ' '); - nonDayCell.className = "nonday"; - } - - function calendarMonth(y, m) { - function onClick(e) { - e.preventDefault(); - callback(y, m, this.textContent); - } - return onClick; - } - - // Draw days of month - let currentDay = 1; - for (let i = startingPos; currentDay <= days; i++) { - if (i % 7 === 0 && currentDay !== 1) { - tableRow = quickElement('tr', tableBody); - } - if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { - todayClass = 'today'; - } else { - todayClass = ''; - } - - // use UTC function; see above for explanation. - if (isSelectedMonth && currentDay === selected.getUTCDate()) { - if (todayClass !== '') { - todayClass += " "; - } - todayClass += "selected"; - } - - const cell = quickElement('td', tableRow, '', 'class', todayClass); - const link = quickElement('a', cell, currentDay, 'href', '#'); - link.addEventListener('click', calendarMonth(year, month)); - currentDay++; - } - - // Draw blanks after end of month (optional, but makes for valid code) - while (tableRow.childNodes.length < 7) { - nonDayCell = quickElement('td', tableRow, ' '); - nonDayCell.className = "nonday"; - } - - calDiv.appendChild(calTable); - } - }; - - // Calendar -- A calendar instance - function Calendar(div_id, callback, selected) { - // div_id (string) is the ID of the element in which the calendar will - // be displayed - // callback (string) is the name of a JavaScript function that will be - // called with the parameters (year, month, day) when a day in the - // calendar is clicked - this.div_id = div_id; - this.callback = callback; - this.today = new Date(); - this.currentMonth = this.today.getMonth() + 1; - this.currentYear = this.today.getFullYear(); - if (typeof selected !== 'undefined') { - this.selected = selected; - } - } - Calendar.prototype = { - drawCurrent: function() { - CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); - }, - drawDate: function(month, year, selected) { - this.currentMonth = month; - this.currentYear = year; - - if(selected) { - this.selected = selected; - } - - this.drawCurrent(); - }, - drawPreviousMonth: function() { - if (this.currentMonth === 1) { - this.currentMonth = 12; - this.currentYear--; - } - else { - this.currentMonth--; - } - this.drawCurrent(); - }, - drawNextMonth: function() { - if (this.currentMonth === 12) { - this.currentMonth = 1; - this.currentYear++; - } - else { - this.currentMonth++; - } - this.drawCurrent(); - }, - drawPreviousYear: function() { - this.currentYear--; - this.drawCurrent(); - }, - drawNextYear: function() { - this.currentYear++; - this.drawCurrent(); - } - }; - window.Calendar = Calendar; - window.CalendarNamespace = CalendarNamespace; -} diff --git a/static/admin/js/cancel.js b/static/admin/js/cancel.js deleted file mode 100644 index 3069c6f..0000000 --- a/static/admin/js/cancel.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; -{ - // Call function fn when the DOM is loaded and ready. If it is already - // loaded, call the function now. - // http://youmightnotneedjquery.com/#ready - function ready(fn) { - if (document.readyState !== 'loading') { - fn(); - } else { - document.addEventListener('DOMContentLoaded', fn); - } - } - - ready(function() { - function handleClick(event) { - event.preventDefault(); - const params = new URLSearchParams(window.location.search); - if (params.has('_popup')) { - window.close(); // Close the popup. - } else { - window.history.back(); // Otherwise, go back. - } - } - - document.querySelectorAll('.cancel-link').forEach(function(el) { - el.addEventListener('click', handleClick); - }); - }); -} diff --git a/static/admin/js/change_form.js b/static/admin/js/change_form.js deleted file mode 100644 index 96a4c62..0000000 --- a/static/admin/js/change_form.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; -{ - const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; - const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; - if (modelName) { - const form = document.getElementById(modelName + '_form'); - for (const element of form.elements) { - // HTMLElement.offsetParent returns null when the element is not - // rendered. - if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { - element.focus(); - break; - } - } - } -} diff --git a/static/admin/js/collapse.js b/static/admin/js/collapse.js deleted file mode 100644 index c6c7b0f..0000000 --- a/static/admin/js/collapse.js +++ /dev/null @@ -1,43 +0,0 @@ -/*global gettext*/ -'use strict'; -{ - window.addEventListener('load', function() { - // Add anchor tag for Show/Hide link - const fieldsets = document.querySelectorAll('fieldset.collapse'); - for (const [i, elem] of fieldsets.entries()) { - // Don't hide if fields in this fieldset have errors - if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { - elem.classList.add('collapsed'); - const h2 = elem.querySelector('h2'); - const link = document.createElement('a'); - link.id = 'fieldsetcollapser' + i; - link.className = 'collapse-toggle'; - link.href = '#'; - link.textContent = gettext('Show'); - h2.appendChild(document.createTextNode(' (')); - h2.appendChild(link); - h2.appendChild(document.createTextNode(')')); - } - } - // Add toggle to hide/show anchor tag - const toggleFunc = function(ev) { - if (ev.target.matches('.collapse-toggle')) { - ev.preventDefault(); - ev.stopPropagation(); - const fieldset = ev.target.closest('fieldset'); - if (fieldset.classList.contains('collapsed')) { - // Show - ev.target.textContent = gettext('Hide'); - fieldset.classList.remove('collapsed'); - } else { - // Hide - ev.target.textContent = gettext('Show'); - fieldset.classList.add('collapsed'); - } - } - }; - document.querySelectorAll('fieldset.module').forEach(function(el) { - el.addEventListener('click', toggleFunc); - }); - }); -} diff --git a/static/admin/js/core.js b/static/admin/js/core.js deleted file mode 100644 index 0344a13..0000000 --- a/static/admin/js/core.js +++ /dev/null @@ -1,170 +0,0 @@ -// Core JavaScript helper functions -'use strict'; - -// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); -function quickElement() { - const obj = document.createElement(arguments[0]); - if (arguments[2]) { - const textNode = document.createTextNode(arguments[2]); - obj.appendChild(textNode); - } - const len = arguments.length; - for (let i = 3; i < len; i += 2) { - obj.setAttribute(arguments[i], arguments[i + 1]); - } - arguments[1].appendChild(obj); - return obj; -} - -// "a" is reference to an object -function removeChildren(a) { - while (a.hasChildNodes()) { - a.removeChild(a.lastChild); - } -} - -// ---------------------------------------------------------------------------- -// Find-position functions by PPK -// See https://www.quirksmode.org/js/findpos.html -// ---------------------------------------------------------------------------- -function findPosX(obj) { - let curleft = 0; - if (obj.offsetParent) { - while (obj.offsetParent) { - curleft += obj.offsetLeft - obj.scrollLeft; - obj = obj.offsetParent; - } - } else if (obj.x) { - curleft += obj.x; - } - return curleft; -} - -function findPosY(obj) { - let curtop = 0; - if (obj.offsetParent) { - while (obj.offsetParent) { - curtop += obj.offsetTop - obj.scrollTop; - obj = obj.offsetParent; - } - } else if (obj.y) { - curtop += obj.y; - } - return curtop; -} - -//----------------------------------------------------------------------------- -// Date object extensions -// ---------------------------------------------------------------------------- -{ - Date.prototype.getTwelveHours = function() { - return this.getHours() % 12 || 12; - }; - - Date.prototype.getTwoDigitMonth = function() { - return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); - }; - - Date.prototype.getTwoDigitDate = function() { - return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); - }; - - Date.prototype.getTwoDigitTwelveHour = function() { - return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); - }; - - Date.prototype.getTwoDigitHour = function() { - return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); - }; - - Date.prototype.getTwoDigitMinute = function() { - return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); - }; - - Date.prototype.getTwoDigitSecond = function() { - return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); - }; - - Date.prototype.getAbbrevMonthName = function() { - return typeof window.CalendarNamespace === "undefined" - ? this.getTwoDigitMonth() - : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; - }; - - Date.prototype.getFullMonthName = function() { - return typeof window.CalendarNamespace === "undefined" - ? this.getTwoDigitMonth() - : window.CalendarNamespace.monthsOfYear[this.getMonth()]; - }; - - Date.prototype.strftime = function(format) { - const fields = { - b: this.getAbbrevMonthName(), - B: this.getFullMonthName(), - c: this.toString(), - d: this.getTwoDigitDate(), - H: this.getTwoDigitHour(), - I: this.getTwoDigitTwelveHour(), - m: this.getTwoDigitMonth(), - M: this.getTwoDigitMinute(), - p: (this.getHours() >= 12) ? 'PM' : 'AM', - S: this.getTwoDigitSecond(), - w: '0' + this.getDay(), - x: this.toLocaleDateString(), - X: this.toLocaleTimeString(), - y: ('' + this.getFullYear()).substr(2, 4), - Y: '' + this.getFullYear(), - '%': '%' - }; - let result = '', i = 0; - while (i < format.length) { - if (format.charAt(i) === '%') { - result += fields[format.charAt(i + 1)]; - ++i; - } - else { - result += format.charAt(i); - } - ++i; - } - return result; - }; - - // ---------------------------------------------------------------------------- - // String object extensions - // ---------------------------------------------------------------------------- - String.prototype.strptime = function(format) { - const split_format = format.split(/[.\-/]/); - const date = this.split(/[.\-/]/); - let i = 0; - let day, month, year; - while (i < split_format.length) { - switch (split_format[i]) { - case "%d": - day = date[i]; - break; - case "%m": - month = date[i] - 1; - break; - case "%Y": - year = date[i]; - break; - case "%y": - // A %y value in the range of [00, 68] is in the current - // century, while [69, 99] is in the previous century, - // according to the Open Group Specification. - if (parseInt(date[i], 10) >= 69) { - year = date[i]; - } else { - year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; - } - break; - } - ++i; - } - // Create Date object from UTC since the parsed value is supposed to be - // in UTC, not local time. Also, the calendar uses UTC functions for - // date extraction. - return new Date(Date.UTC(year, month, day)); - }; -} diff --git a/static/admin/js/filters.js b/static/admin/js/filters.js deleted file mode 100644 index f5536eb..0000000 --- a/static/admin/js/filters.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Persist changelist filters state (collapsed/expanded). - */ -'use strict'; -{ - // Init filters. - let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); - - if (!filters) { - filters = {}; - } - - Object.entries(filters).forEach(([key, value]) => { - const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); - - // Check if the filter is present, it could be from other view. - if (detailElement) { - value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); - } - }); - - // Save filter state when clicks. - const details = document.querySelectorAll('details'); - details.forEach(detail => { - detail.addEventListener('toggle', event => { - filters[`${event.target.dataset.filterTitle}`] = detail.open; - sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); - }); - }); -} diff --git a/static/admin/js/inlines.js b/static/admin/js/inlines.js deleted file mode 100644 index e9a1dfe..0000000 --- a/static/admin/js/inlines.js +++ /dev/null @@ -1,359 +0,0 @@ -/*global DateTimeShortcuts, SelectFilter*/ -/** - * Django admin inlines - * - * Based on jQuery Formset 1.1 - * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) - * @requires jQuery 1.2.6 or later - * - * Copyright (c) 2009, Stanislaus Madueke - * All rights reserved. - * - * Spiced up with Code from Zain Memon's GSoC project 2009 - * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. - * - * Licensed under the New BSD License - * See: https://opensource.org/licenses/bsd-license.php - */ -'use strict'; -{ - const $ = django.jQuery; - $.fn.formset = function(opts) { - const options = $.extend({}, $.fn.formset.defaults, opts); - const $this = $(this); - const $parent = $this.parent(); - const updateElementIndex = function(el, prefix, ndx) { - const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); - const replacement = prefix + "-" + ndx; - if ($(el).prop("for")) { - $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); - } - if (el.id) { - el.id = el.id.replace(id_regex, replacement); - } - if (el.name) { - el.name = el.name.replace(id_regex, replacement); - } - }; - const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); - let nextIndex = parseInt(totalForms.val(), 10); - const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); - const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); - let addButton; - - /** - * The "Add another MyModel" button below the inline forms. - */ - const addInlineAddButton = function() { - if (addButton === null) { - if ($this.prop("tagName") === "TR") { - // If forms are laid out as table rows, insert the - // "add" button in a new table row: - const numCols = $this.eq(-1).children().length; - $parent.append('' + options.addText + ""); - addButton = $parent.find("tr:last a"); - } else { - // Otherwise, insert it immediately after the last form: - $this.filter(":last").after('"); - addButton = $this.filter(":last").next().find("a"); - } - } - addButton.on('click', addInlineClickHandler); - }; - - const addInlineClickHandler = function(e) { - e.preventDefault(); - const template = $("#" + options.prefix + "-empty"); - const row = template.clone(true); - row.removeClass(options.emptyCssClass) - .addClass(options.formCssClass) - .attr("id", options.prefix + "-" + nextIndex); - addInlineDeleteButton(row); - row.find("*").each(function() { - updateElementIndex(this, options.prefix, totalForms.val()); - }); - // Insert the new form when it has been fully edited. - row.insertBefore($(template)); - // Update number of total forms. - $(totalForms).val(parseInt(totalForms.val(), 10) + 1); - nextIndex += 1; - // Hide the add button if there's a limit and it's been reached. - if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { - addButton.parent().hide(); - } - // Show the remove buttons if there are more than min_num. - toggleDeleteButtonVisibility(row.closest('.inline-group')); - - // Pass the new form to the post-add callback, if provided. - if (options.added) { - options.added(row); - } - row.get(0).dispatchEvent(new CustomEvent("formset:added", { - bubbles: true, - detail: { - formsetName: options.prefix - } - })); - }; - - /** - * The "X" button that is part of every unsaved inline. - * (When saved, it is replaced with a "Delete" checkbox.) - */ - const addInlineDeleteButton = function(row) { - if (row.is("tr")) { - // If the forms are laid out in table rows, insert - // the remove button into the last table cell: - row.children(":last").append('"); - } else if (row.is("ul") || row.is("ol")) { - // If they're laid out as an ordered/unordered list, - // insert an
  • after the last list item: - row.append('
  • ' + options.deleteText + "
  • "); - } else { - // Otherwise, just insert the remove button as the - // last child element of the form's container: - row.children(":first").append('' + options.deleteText + ""); - } - // Add delete handler for each row. - row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); - }; - - const inlineDeleteHandler = function(e1) { - e1.preventDefault(); - const deleteButton = $(e1.target); - const row = deleteButton.closest('.' + options.formCssClass); - const inlineGroup = row.closest('.inline-group'); - // Remove the parent form containing this button, - // and also remove the relevant row with non-field errors: - const prevRow = row.prev(); - if (prevRow.length && prevRow.hasClass('row-form-errors')) { - prevRow.remove(); - } - row.remove(); - nextIndex -= 1; - // Pass the deleted form to the post-delete callback, if provided. - if (options.removed) { - options.removed(row); - } - document.dispatchEvent(new CustomEvent("formset:removed", { - detail: { - formsetName: options.prefix - } - })); - // Update the TOTAL_FORMS form count. - const forms = $("." + options.formCssClass); - $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); - // Show add button again once below maximum number. - if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { - addButton.parent().show(); - } - // Hide the remove buttons if at min_num. - toggleDeleteButtonVisibility(inlineGroup); - // Also, update names and ids for all remaining form controls so - // they remain in sequence: - let i, formCount; - const updateElementCallback = function() { - updateElementIndex(this, options.prefix, i); - }; - for (i = 0, formCount = forms.length; i < formCount; i++) { - updateElementIndex($(forms).get(i), options.prefix, i); - $(forms.get(i)).find("*").each(updateElementCallback); - } - }; - - const toggleDeleteButtonVisibility = function(inlineGroup) { - if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { - inlineGroup.find('.inline-deletelink').hide(); - } else { - inlineGroup.find('.inline-deletelink').show(); - } - }; - - $this.each(function(i) { - $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); - }); - - // Create the delete buttons for all unsaved inlines: - $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { - addInlineDeleteButton($(this)); - }); - toggleDeleteButtonVisibility($this); - - // Create the add button, initially hidden. - addButton = options.addButton; - addInlineAddButton(); - - // Show the add button if allowed to add more items. - // Note that max_num = None translates to a blank string. - const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; - if ($this.length && showAddButton) { - addButton.parent().show(); - } else { - addButton.parent().hide(); - } - - return this; - }; - - /* Setup plugin defaults */ - $.fn.formset.defaults = { - prefix: "form", // The form prefix for your django formset - addText: "add another", // Text for the add link - deleteText: "remove", // Text for the delete link - addCssClass: "add-row", // CSS class applied to the add link - deleteCssClass: "delete-row", // CSS class applied to the delete link - emptyCssClass: "empty-row", // CSS class applied to the empty row - formCssClass: "dynamic-form", // CSS class applied to each form in a formset - added: null, // Function called each time a new form is added - removed: null, // Function called each time a form is deleted - addButton: null // Existing add button to use - }; - - - // Tabular inlines --------------------------------------------------------- - $.fn.tabularFormset = function(selector, options) { - const $rows = $(this); - - const reinitDateTimeShortCuts = function() { - // Reinitialize the calendar and clock widgets by force - if (typeof DateTimeShortcuts !== "undefined") { - $(".datetimeshortcuts").remove(); - DateTimeShortcuts.init(); - } - }; - - const updateSelectFilter = function() { - // If any SelectFilter widgets are a part of the new form, - // instantiate a new SelectFilter instance for it. - if (typeof SelectFilter !== 'undefined') { - $('.selectfilter').each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, false); - }); - $('.selectfilterstacked').each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, true); - }); - } - }; - - const initPrepopulatedFields = function(row) { - row.find('.prepopulated_field').each(function() { - const field = $(this), - input = field.find('input, select, textarea'), - dependency_list = input.data('dependency_list') || [], - dependencies = []; - $.each(dependency_list, function(i, field_name) { - dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); - }); - if (dependencies.length) { - input.prepopulate(dependencies, input.attr('maxlength')); - } - }); - }; - - $rows.formset({ - prefix: options.prefix, - addText: options.addText, - formCssClass: "dynamic-" + options.prefix, - deleteCssClass: "inline-deletelink", - deleteText: options.deleteText, - emptyCssClass: "empty-form", - added: function(row) { - initPrepopulatedFields(row); - reinitDateTimeShortCuts(); - updateSelectFilter(); - }, - addButton: options.addButton - }); - - return $rows; - }; - - // Stacked inlines --------------------------------------------------------- - $.fn.stackedFormset = function(selector, options) { - const $rows = $(this); - const updateInlineLabel = function(row) { - $(selector).find(".inline_label").each(function(i) { - const count = i + 1; - $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); - }); - }; - - const reinitDateTimeShortCuts = function() { - // Reinitialize the calendar and clock widgets by force, yuck. - if (typeof DateTimeShortcuts !== "undefined") { - $(".datetimeshortcuts").remove(); - DateTimeShortcuts.init(); - } - }; - - const updateSelectFilter = function() { - // If any SelectFilter widgets were added, instantiate a new instance. - if (typeof SelectFilter !== "undefined") { - $(".selectfilter").each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, false); - }); - $(".selectfilterstacked").each(function(index, value) { - SelectFilter.init(value.id, this.dataset.fieldName, true); - }); - } - }; - - const initPrepopulatedFields = function(row) { - row.find('.prepopulated_field').each(function() { - const field = $(this), - input = field.find('input, select, textarea'), - dependency_list = input.data('dependency_list') || [], - dependencies = []; - $.each(dependency_list, function(i, field_name) { - // Dependency in a fieldset. - let field_element = row.find('.form-row .field-' + field_name); - // Dependency without a fieldset. - if (!field_element.length) { - field_element = row.find('.form-row.field-' + field_name); - } - dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); - }); - if (dependencies.length) { - input.prepopulate(dependencies, input.attr('maxlength')); - } - }); - }; - - $rows.formset({ - prefix: options.prefix, - addText: options.addText, - formCssClass: "dynamic-" + options.prefix, - deleteCssClass: "inline-deletelink", - deleteText: options.deleteText, - emptyCssClass: "empty-form", - removed: updateInlineLabel, - added: function(row) { - initPrepopulatedFields(row); - reinitDateTimeShortCuts(); - updateSelectFilter(); - updateInlineLabel(row); - }, - addButton: options.addButton - }); - - return $rows; - }; - - $(document).ready(function() { - $(".js-inline-admin-formset").each(function() { - const data = $(this).data(), - inlineOptions = data.inlineFormset; - let selector; - switch(data.inlineType) { - case "stacked": - selector = inlineOptions.name + "-group .inline-related"; - $(selector).stackedFormset(selector, inlineOptions.options); - break; - case "tabular": - selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; - $(selector).tabularFormset(selector, inlineOptions.options); - break; - } - }); - }); -} diff --git a/static/admin/js/jquery.init.js b/static/admin/js/jquery.init.js deleted file mode 100644 index f40b27f..0000000 --- a/static/admin/js/jquery.init.js +++ /dev/null @@ -1,8 +0,0 @@ -/*global jQuery:false*/ -'use strict'; -/* Puts the included jQuery into our own namespace using noConflict and passing - * it 'true'. This ensures that the included jQuery doesn't pollute the global - * namespace (i.e. this preserves pre-existing values for both window.$ and - * window.jQuery). - */ -window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/static/admin/js/nav_sidebar.js b/static/admin/js/nav_sidebar.js deleted file mode 100644 index 7e735db..0000000 --- a/static/admin/js/nav_sidebar.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; -{ - const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); - if (toggleNavSidebar !== null) { - const navSidebar = document.getElementById('nav-sidebar'); - const main = document.getElementById('main'); - let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); - if (navSidebarIsOpen === null) { - navSidebarIsOpen = 'true'; - } - main.classList.toggle('shifted', navSidebarIsOpen === 'true'); - navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); - - toggleNavSidebar.addEventListener('click', function() { - if (navSidebarIsOpen === 'true') { - navSidebarIsOpen = 'false'; - } else { - navSidebarIsOpen = 'true'; - } - localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); - main.classList.toggle('shifted'); - navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); - }); - } - - function initSidebarQuickFilter() { - const options = []; - const navSidebar = document.getElementById('nav-sidebar'); - if (!navSidebar) { - return; - } - navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { - options.push({title: container.innerHTML, node: container}); - }); - - function checkValue(event) { - let filterValue = event.target.value; - if (filterValue) { - filterValue = filterValue.toLowerCase(); - } - if (event.key === 'Escape') { - filterValue = ''; - event.target.value = ''; // clear input - } - let matches = false; - for (const o of options) { - let displayValue = ''; - if (filterValue) { - if (o.title.toLowerCase().indexOf(filterValue) === -1) { - displayValue = 'none'; - } else { - matches = true; - } - } - // show/hide parent - o.node.parentNode.parentNode.style.display = displayValue; - } - if (!filterValue || matches) { - event.target.classList.remove('no-results'); - } else { - event.target.classList.add('no-results'); - } - sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); - } - - const nav = document.getElementById('nav-filter'); - nav.addEventListener('change', checkValue, false); - nav.addEventListener('input', checkValue, false); - nav.addEventListener('keyup', checkValue, false); - - const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); - if (storedValue) { - nav.value = storedValue; - checkValue({target: nav, key: ''}); - } - } - window.initSidebarQuickFilter = initSidebarQuickFilter; - initSidebarQuickFilter(); -} diff --git a/static/admin/js/popup_response.js b/static/admin/js/popup_response.js deleted file mode 100644 index 2b1d3dd..0000000 --- a/static/admin/js/popup_response.js +++ /dev/null @@ -1,16 +0,0 @@ -/*global opener */ -'use strict'; -{ - const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); - switch(initData.action) { - case 'change': - opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); - break; - case 'delete': - opener.dismissDeleteRelatedObjectPopup(window, initData.value); - break; - default: - opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); - break; - } -} diff --git a/static/admin/js/prepopulate.js b/static/admin/js/prepopulate.js deleted file mode 100644 index 89e95ab..0000000 --- a/static/admin/js/prepopulate.js +++ /dev/null @@ -1,43 +0,0 @@ -/*global URLify*/ -'use strict'; -{ - const $ = django.jQuery; - $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { - /* - Depends on urlify.js - Populates a selected field with the values of the dependent fields, - URLifies and shortens the string. - dependencies - array of dependent fields ids - maxLength - maximum length of the URLify'd string - allowUnicode - Unicode support of the URLify'd string - */ - return this.each(function() { - const prepopulatedField = $(this); - - const populate = function() { - // Bail if the field's value has been changed by the user - if (prepopulatedField.data('_changed')) { - return; - } - - const values = []; - $.each(dependencies, function(i, field) { - field = $(field); - if (field.val().length > 0) { - values.push(field.val()); - } - }); - prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); - }; - - prepopulatedField.data('_changed', false); - prepopulatedField.on('change', function() { - prepopulatedField.data('_changed', true); - }); - - if (!prepopulatedField.val()) { - $(dependencies.join(',')).on('keyup change focus', populate); - } - }); - }; -} diff --git a/static/admin/js/prepopulate_init.js b/static/admin/js/prepopulate_init.js deleted file mode 100644 index a58841f..0000000 --- a/static/admin/js/prepopulate_init.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; -{ - const $ = django.jQuery; - const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); - $.each(fields, function(index, field) { - $( - '.empty-form .form-row .field-' + field.name + - ', .empty-form.form-row .field-' + field.name + - ', .empty-form .form-row.field-' + field.name - ).addClass('prepopulated_field'); - $(field.id).data('dependency_list', field.dependency_list).prepopulate( - field.dependency_ids, field.maxLength, field.allowUnicode - ); - }); -} diff --git a/static/admin/js/theme.js b/static/admin/js/theme.js deleted file mode 100644 index 794cd15..0000000 --- a/static/admin/js/theme.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; -{ - window.addEventListener('load', function(e) { - - function setTheme(mode) { - if (mode !== "light" && mode !== "dark" && mode !== "auto") { - console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); - mode = "auto"; - } - document.documentElement.dataset.theme = mode; - localStorage.setItem("theme", mode); - } - - function cycleTheme() { - const currentTheme = localStorage.getItem("theme") || "auto"; - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - - if (prefersDark) { - // Auto (dark) -> Light -> Dark - if (currentTheme === "auto") { - setTheme("light"); - } else if (currentTheme === "light") { - setTheme("dark"); - } else { - setTheme("auto"); - } - } else { - // Auto (light) -> Dark -> Light - if (currentTheme === "auto") { - setTheme("dark"); - } else if (currentTheme === "dark") { - setTheme("light"); - } else { - setTheme("auto"); - } - } - } - - function initTheme() { - // set theme defined in localStorage if there is one, or fallback to auto mode - const currentTheme = localStorage.getItem("theme"); - currentTheme ? setTheme(currentTheme) : setTheme("auto"); - } - - function setupTheme() { - // Attach event handlers for toggling themes - const buttons = document.getElementsByClassName("theme-toggle"); - Array.from(buttons).forEach((btn) => { - btn.addEventListener("click", cycleTheme); - }); - initTheme(); - } - - setupTheme(); - }); -} diff --git a/static/admin/js/urlify.js b/static/admin/js/urlify.js deleted file mode 100644 index 9fc0409..0000000 --- a/static/admin/js/urlify.js +++ /dev/null @@ -1,169 +0,0 @@ -/*global XRegExp*/ -'use strict'; -{ - const LATIN_MAP = { - 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', - 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', - 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', - 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', - 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', - 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', - 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', - 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', - 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', - 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' - }; - const LATIN_SYMBOLS_MAP = { - '©': '(c)' - }; - const GREEK_MAP = { - 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', - 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', - 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', - 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', - 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', - 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', - 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', - 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', - 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', - 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' - }; - const TURKISH_MAP = { - 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', - 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' - }; - const ROMANIAN_MAP = { - 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', - 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' - }; - const RUSSIAN_MAP = { - 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', - 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', - 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', - 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', - 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', - 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', - 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', - 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', - 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', - 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' - }; - const UKRAINIAN_MAP = { - 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', - 'ї': 'yi', 'ґ': 'g' - }; - const CZECH_MAP = { - 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', - 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', - 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' - }; - const SLOVAK_MAP = { - 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', - 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', - 'ú': 'u', 'ý': 'y', 'ž': 'z', - 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', - 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', - 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' - }; - const POLISH_MAP = { - 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', - 'ź': 'z', 'ż': 'z', - 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', - 'Ź': 'Z', 'Ż': 'Z' - }; - const LATVIAN_MAP = { - 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', - 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', - 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', - 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' - }; - const ARABIC_MAP = { - 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', - 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', - 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', - 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' - }; - const LITHUANIAN_MAP = { - 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', - 'ū': 'u', 'ž': 'z', - 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', - 'Ū': 'U', 'Ž': 'Z' - }; - const SERBIAN_MAP = { - 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', - 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', - 'Џ': 'Dz', 'Đ': 'Dj' - }; - const AZERBAIJANI_MAP = { - 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', - 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' - }; - const GEORGIAN_MAP = { - 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', - 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', - 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', - 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', - 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' - }; - - const ALL_DOWNCODE_MAPS = [ - LATIN_MAP, - LATIN_SYMBOLS_MAP, - GREEK_MAP, - TURKISH_MAP, - ROMANIAN_MAP, - RUSSIAN_MAP, - UKRAINIAN_MAP, - CZECH_MAP, - SLOVAK_MAP, - POLISH_MAP, - LATVIAN_MAP, - ARABIC_MAP, - LITHUANIAN_MAP, - SERBIAN_MAP, - AZERBAIJANI_MAP, - GEORGIAN_MAP - ]; - - const Downcoder = { - 'Initialize': function() { - if (Downcoder.map) { // already made - return; - } - Downcoder.map = {}; - for (const lookup of ALL_DOWNCODE_MAPS) { - Object.assign(Downcoder.map, lookup); - } - Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); - } - }; - - function downcode(slug) { - Downcoder.Initialize(); - return slug.replace(Downcoder.regex, function(m) { - return Downcoder.map[m]; - }); - } - - - function URLify(s, num_chars, allowUnicode) { - // changes, e.g., "Petty theft" to "petty-theft" - if (!allowUnicode) { - s = downcode(s); - } - s = s.toLowerCase(); // convert to lowercase - // if downcode doesn't hit, the char will be stripped here - if (allowUnicode) { - // Keep Unicode letters including both lowercase and uppercase - // characters, whitespace, and dash; remove other characters. - s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); - } else { - s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars - } - s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces - s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens - s = s.substring(0, num_chars); // trim to first num_chars chars - return s.replace(/-+$/g, ''); // trim any trailing hyphens - } - window.URLify = URLify; -} diff --git a/static/admin/js/vendor/jquery/LICENSE.txt b/static/admin/js/vendor/jquery/LICENSE.txt deleted file mode 100644 index f642c3f..0000000 --- a/static/admin/js/vendor/jquery/LICENSE.txt +++ /dev/null @@ -1,20 +0,0 @@ -Copyright OpenJS Foundation and other contributors, https://openjsf.org/ - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/admin/js/vendor/jquery/jquery.js b/static/admin/js/vendor/jquery/jquery.js deleted file mode 100644 index 7f35c11..0000000 --- a/static/admin/js/vendor/jquery/jquery.js +++ /dev/null @@ -1,10965 +0,0 @@ -/*! - * jQuery JavaScript Library v3.6.4 - * https://jquery.com/ - * - * Includes Sizzle.js - * https://sizzlejs.com/ - * - * Copyright OpenJS Foundation and other contributors - * Released under the MIT license - * https://jquery.org/license - * - * Date: 2023-03-08T15:28Z - */ -( function( global, factory ) { - - "use strict"; - - if ( typeof module === "object" && typeof module.exports === "object" ) { - - // For CommonJS and CommonJS-like environments where a proper `window` - // is present, execute the factory and get jQuery. - // For environments that do not have a `window` with a `document` - // (such as Node.js), expose a factory as module.exports. - // This accentuates the need for the creation of a real `window`. - // e.g. var jQuery = require("jquery")(window); - // See ticket trac-14549 for more info. - module.exports = global.document ? - factory( global, true ) : - function( w ) { - if ( !w.document ) { - throw new Error( "jQuery requires a window with a document" ); - } - return factory( w ); - }; - } else { - factory( global ); - } - -// Pass this if window is not defined yet -} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { - -// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 -// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode -// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common -// enough that all such attempts are guarded in a try block. -"use strict"; - -var arr = []; - -var getProto = Object.getPrototypeOf; - -var slice = arr.slice; - -var flat = arr.flat ? function( array ) { - return arr.flat.call( array ); -} : function( array ) { - return arr.concat.apply( [], array ); -}; - - -var push = arr.push; - -var indexOf = arr.indexOf; - -var class2type = {}; - -var toString = class2type.toString; - -var hasOwn = class2type.hasOwnProperty; - -var fnToString = hasOwn.toString; - -var ObjectFunctionString = fnToString.call( Object ); - -var support = {}; - -var isFunction = function isFunction( obj ) { - - // Support: Chrome <=57, Firefox <=52 - // In some browsers, typeof returns "function" for HTML elements - // (i.e., `typeof document.createElement( "object" ) === "function"`). - // We don't want to classify *any* DOM node as a function. - // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 - // Plus for old WebKit, typeof returns "function" for HTML collections - // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) - return typeof obj === "function" && typeof obj.nodeType !== "number" && - typeof obj.item !== "function"; - }; - - -var isWindow = function isWindow( obj ) { - return obj != null && obj === obj.window; - }; - - -var document = window.document; - - - - var preservedScriptAttributes = { - type: true, - src: true, - nonce: true, - noModule: true - }; - - function DOMEval( code, node, doc ) { - doc = doc || document; - - var i, val, - script = doc.createElement( "script" ); - - script.text = code; - if ( node ) { - for ( i in preservedScriptAttributes ) { - - // Support: Firefox 64+, Edge 18+ - // Some browsers don't support the "nonce" property on scripts. - // On the other hand, just using `getAttribute` is not enough as - // the `nonce` attribute is reset to an empty string whenever it - // becomes browsing-context connected. - // See https://github.com/whatwg/html/issues/2369 - // See https://html.spec.whatwg.org/#nonce-attributes - // The `node.getAttribute` check was added for the sake of - // `jQuery.globalEval` so that it can fake a nonce-containing node - // via an object. - val = node[ i ] || node.getAttribute && node.getAttribute( i ); - if ( val ) { - script.setAttribute( i, val ); - } - } - } - doc.head.appendChild( script ).parentNode.removeChild( script ); - } - - -function toType( obj ) { - if ( obj == null ) { - return obj + ""; - } - - // Support: Android <=2.3 only (functionish RegExp) - return typeof obj === "object" || typeof obj === "function" ? - class2type[ toString.call( obj ) ] || "object" : - typeof obj; -} -/* global Symbol */ -// Defining this global in .eslintrc.json would create a danger of using the global -// unguarded in another place, it seems safer to define global only for this module - - - -var - version = "3.6.4", - - // Define a local copy of jQuery - jQuery = function( selector, context ) { - - // The jQuery object is actually just the init constructor 'enhanced' - // Need init if jQuery is called (just allow error to be thrown if not included) - return new jQuery.fn.init( selector, context ); - }; - -jQuery.fn = jQuery.prototype = { - - // The current version of jQuery being used - jquery: version, - - constructor: jQuery, - - // The default length of a jQuery object is 0 - length: 0, - - toArray: function() { - return slice.call( this ); - }, - - // Get the Nth element in the matched element set OR - // Get the whole matched element set as a clean array - get: function( num ) { - - // Return all the elements in a clean array - if ( num == null ) { - return slice.call( this ); - } - - // Return just the one element from the set - return num < 0 ? this[ num + this.length ] : this[ num ]; - }, - - // Take an array of elements and push it onto the stack - // (returning the new matched element set) - pushStack: function( elems ) { - - // Build a new jQuery matched element set - var ret = jQuery.merge( this.constructor(), elems ); - - // Add the old object onto the stack (as a reference) - ret.prevObject = this; - - // Return the newly-formed element set - return ret; - }, - - // Execute a callback for every element in the matched set. - each: function( callback ) { - return jQuery.each( this, callback ); - }, - - map: function( callback ) { - return this.pushStack( jQuery.map( this, function( elem, i ) { - return callback.call( elem, i, elem ); - } ) ); - }, - - slice: function() { - return this.pushStack( slice.apply( this, arguments ) ); - }, - - first: function() { - return this.eq( 0 ); - }, - - last: function() { - return this.eq( -1 ); - }, - - even: function() { - return this.pushStack( jQuery.grep( this, function( _elem, i ) { - return ( i + 1 ) % 2; - } ) ); - }, - - odd: function() { - return this.pushStack( jQuery.grep( this, function( _elem, i ) { - return i % 2; - } ) ); - }, - - eq: function( i ) { - var len = this.length, - j = +i + ( i < 0 ? len : 0 ); - return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); - }, - - end: function() { - return this.prevObject || this.constructor(); - }, - - // For internal use only. - // Behaves like an Array's method, not like a jQuery method. - push: push, - sort: arr.sort, - splice: arr.splice -}; - -jQuery.extend = jQuery.fn.extend = function() { - var options, name, src, copy, copyIsArray, clone, - target = arguments[ 0 ] || {}, - i = 1, - length = arguments.length, - deep = false; - - // Handle a deep copy situation - if ( typeof target === "boolean" ) { - deep = target; - - // Skip the boolean and the target - target = arguments[ i ] || {}; - i++; - } - - // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !isFunction( target ) ) { - target = {}; - } - - // Extend jQuery itself if only one argument is passed - if ( i === length ) { - target = this; - i--; - } - - for ( ; i < length; i++ ) { - - // Only deal with non-null/undefined values - if ( ( options = arguments[ i ] ) != null ) { - - // Extend the base object - for ( name in options ) { - copy = options[ name ]; - - // Prevent Object.prototype pollution - // Prevent never-ending loop - if ( name === "__proto__" || target === copy ) { - continue; - } - - // Recurse if we're merging plain objects or arrays - if ( deep && copy && ( jQuery.isPlainObject( copy ) || - ( copyIsArray = Array.isArray( copy ) ) ) ) { - src = target[ name ]; - - // Ensure proper type for the source value - if ( copyIsArray && !Array.isArray( src ) ) { - clone = []; - } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { - clone = {}; - } else { - clone = src; - } - copyIsArray = false; - - // Never move original objects, clone them - target[ name ] = jQuery.extend( deep, clone, copy ); - - // Don't bring in undefined values - } else if ( copy !== undefined ) { - target[ name ] = copy; - } - } - } - } - - // Return the modified object - return target; -}; - -jQuery.extend( { - - // Unique for each copy of jQuery on the page - expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), - - // Assume jQuery is ready without the ready module - isReady: true, - - error: function( msg ) { - throw new Error( msg ); - }, - - noop: function() {}, - - isPlainObject: function( obj ) { - var proto, Ctor; - - // Detect obvious negatives - // Use toString instead of jQuery.type to catch host objects - if ( !obj || toString.call( obj ) !== "[object Object]" ) { - return false; - } - - proto = getProto( obj ); - - // Objects with no prototype (e.g., `Object.create( null )`) are plain - if ( !proto ) { - return true; - } - - // Objects with prototype are plain iff they were constructed by a global Object function - Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; - return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; - }, - - isEmptyObject: function( obj ) { - var name; - - for ( name in obj ) { - return false; - } - return true; - }, - - // Evaluates a script in a provided context; falls back to the global one - // if not specified. - globalEval: function( code, options, doc ) { - DOMEval( code, { nonce: options && options.nonce }, doc ); - }, - - each: function( obj, callback ) { - var length, i = 0; - - if ( isArrayLike( obj ) ) { - length = obj.length; - for ( ; i < length; i++ ) { - if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { - break; - } - } - } else { - for ( i in obj ) { - if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { - break; - } - } - } - - return obj; - }, - - // results is for internal usage only - makeArray: function( arr, results ) { - var ret = results || []; - - if ( arr != null ) { - if ( isArrayLike( Object( arr ) ) ) { - jQuery.merge( ret, - typeof arr === "string" ? - [ arr ] : arr - ); - } else { - push.call( ret, arr ); - } - } - - return ret; - }, - - inArray: function( elem, arr, i ) { - return arr == null ? -1 : indexOf.call( arr, elem, i ); - }, - - // Support: Android <=4.0 only, PhantomJS 1 only - // push.apply(_, arraylike) throws on ancient WebKit - merge: function( first, second ) { - var len = +second.length, - j = 0, - i = first.length; - - for ( ; j < len; j++ ) { - first[ i++ ] = second[ j ]; - } - - first.length = i; - - return first; - }, - - grep: function( elems, callback, invert ) { - var callbackInverse, - matches = [], - i = 0, - length = elems.length, - callbackExpect = !invert; - - // Go through the array, only saving the items - // that pass the validator function - for ( ; i < length; i++ ) { - callbackInverse = !callback( elems[ i ], i ); - if ( callbackInverse !== callbackExpect ) { - matches.push( elems[ i ] ); - } - } - - return matches; - }, - - // arg is for internal usage only - map: function( elems, callback, arg ) { - var length, value, - i = 0, - ret = []; - - // Go through the array, translating each of the items to their new values - if ( isArrayLike( elems ) ) { - length = elems.length; - for ( ; i < length; i++ ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - - // Go through every key on the object, - } else { - for ( i in elems ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - } - - // Flatten any nested arrays - return flat( ret ); - }, - - // A global GUID counter for objects - guid: 1, - - // jQuery.support is not used in Core but other projects attach their - // properties to it so it needs to exist. - support: support -} ); - -if ( typeof Symbol === "function" ) { - jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; -} - -// Populate the class2type map -jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), - function( _i, name ) { - class2type[ "[object " + name + "]" ] = name.toLowerCase(); - } ); - -function isArrayLike( obj ) { - - // Support: real iOS 8.2 only (not reproducible in simulator) - // `in` check used to prevent JIT error (gh-2145) - // hasOwn isn't used here due to false negatives - // regarding Nodelist length in IE - var length = !!obj && "length" in obj && obj.length, - type = toType( obj ); - - if ( isFunction( obj ) || isWindow( obj ) ) { - return false; - } - - return type === "array" || length === 0 || - typeof length === "number" && length > 0 && ( length - 1 ) in obj; -} -var Sizzle = -/*! - * Sizzle CSS Selector Engine v2.3.10 - * https://sizzlejs.com/ - * - * Copyright JS Foundation and other contributors - * Released under the MIT license - * https://js.foundation/ - * - * Date: 2023-02-14 - */ -( function( window ) { -var i, - support, - Expr, - getText, - isXML, - tokenize, - compile, - select, - outermostContext, - sortInput, - hasDuplicate, - - // Local document vars - setDocument, - document, - docElem, - documentIsHTML, - rbuggyQSA, - rbuggyMatches, - matches, - contains, - - // Instance-specific data - expando = "sizzle" + 1 * new Date(), - preferredDoc = window.document, - dirruns = 0, - done = 0, - classCache = createCache(), - tokenCache = createCache(), - compilerCache = createCache(), - nonnativeSelectorCache = createCache(), - sortOrder = function( a, b ) { - if ( a === b ) { - hasDuplicate = true; - } - return 0; - }, - - // Instance methods - hasOwn = ( {} ).hasOwnProperty, - arr = [], - pop = arr.pop, - pushNative = arr.push, - push = arr.push, - slice = arr.slice, - - // Use a stripped-down indexOf as it's faster than native - // https://jsperf.com/thor-indexof-vs-for/5 - indexOf = function( list, elem ) { - var i = 0, - len = list.length; - for ( ; i < len; i++ ) { - if ( list[ i ] === elem ) { - return i; - } - } - return -1; - }, - - booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + - "ismap|loop|multiple|open|readonly|required|scoped", - - // Regular expressions - - // http://www.w3.org/TR/css3-selectors/#whitespace - whitespace = "[\\x20\\t\\r\\n\\f]", - - // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram - identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + - "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", - - // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors - attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + - - // Operator (capture 2) - "*([*^$|!~]?=)" + whitespace + - - // "Attribute values must be CSS identifiers [capture 5] - // or strings [capture 3 or capture 4]" - "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + - whitespace + "*\\]", - - pseudos = ":(" + identifier + ")(?:\\((" + - - // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: - // 1. quoted (capture 3; capture 4 or capture 5) - "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + - - // 2. simple (capture 6) - "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + - - // 3. anything else (capture 2) - ".*" + - ")\\)|)", - - // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter - rwhitespace = new RegExp( whitespace + "+", "g" ), - rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + - whitespace + "+$", "g" ), - - rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), - rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + - "*" ), - rdescend = new RegExp( whitespace + "|>" ), - - rpseudo = new RegExp( pseudos ), - ridentifier = new RegExp( "^" + identifier + "$" ), - - matchExpr = { - "ID": new RegExp( "^#(" + identifier + ")" ), - "CLASS": new RegExp( "^\\.(" + identifier + ")" ), - "TAG": new RegExp( "^(" + identifier + "|[*])" ), - "ATTR": new RegExp( "^" + attributes ), - "PSEUDO": new RegExp( "^" + pseudos ), - "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + - whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + - whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), - "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), - - // For use in libraries implementing .is() - // We use this for POS matching in `select` - "needsContext": new RegExp( "^" + whitespace + - "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + - "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) - }, - - rhtml = /HTML$/i, - rinputs = /^(?:input|select|textarea|button)$/i, - rheader = /^h\d$/i, - - rnative = /^[^{]+\{\s*\[native \w/, - - // Easily-parseable/retrievable ID or TAG or CLASS selectors - rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, - - rsibling = /[+~]/, - - // CSS escapes - // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters - runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), - funescape = function( escape, nonHex ) { - var high = "0x" + escape.slice( 1 ) - 0x10000; - - return nonHex ? - - // Strip the backslash prefix from a non-hex escape sequence - nonHex : - - // Replace a hexadecimal escape sequence with the encoded Unicode code point - // Support: IE <=11+ - // For values outside the Basic Multilingual Plane (BMP), manually construct a - // surrogate pair - high < 0 ? - String.fromCharCode( high + 0x10000 ) : - String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); - }, - - // CSS string/identifier serialization - // https://drafts.csswg.org/cssom/#common-serializing-idioms - rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, - fcssescape = function( ch, asCodePoint ) { - if ( asCodePoint ) { - - // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER - if ( ch === "\0" ) { - return "\uFFFD"; - } - - // Control characters and (dependent upon position) numbers get escaped as code points - return ch.slice( 0, -1 ) + "\\" + - ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; - } - - // Other potentially-special ASCII characters get backslash-escaped - return "\\" + ch; - }, - - // Used for iframes - // See setDocument() - // Removing the function wrapper causes a "Permission Denied" - // error in IE - unloadHandler = function() { - setDocument(); - }, - - inDisabledFieldset = addCombinator( - function( elem ) { - return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; - }, - { dir: "parentNode", next: "legend" } - ); - -// Optimize for push.apply( _, NodeList ) -try { - push.apply( - ( arr = slice.call( preferredDoc.childNodes ) ), - preferredDoc.childNodes - ); - - // Support: Android<4.0 - // Detect silently failing push.apply - // eslint-disable-next-line no-unused-expressions - arr[ preferredDoc.childNodes.length ].nodeType; -} catch ( e ) { - push = { apply: arr.length ? - - // Leverage slice if possible - function( target, els ) { - pushNative.apply( target, slice.call( els ) ); - } : - - // Support: IE<9 - // Otherwise append directly - function( target, els ) { - var j = target.length, - i = 0; - - // Can't trust NodeList.length - while ( ( target[ j++ ] = els[ i++ ] ) ) {} - target.length = j - 1; - } - }; -} - -function Sizzle( selector, context, results, seed ) { - var m, i, elem, nid, match, groups, newSelector, - newContext = context && context.ownerDocument, - - // nodeType defaults to 9, since context defaults to document - nodeType = context ? context.nodeType : 9; - - results = results || []; - - // Return early from calls with invalid selector or context - if ( typeof selector !== "string" || !selector || - nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { - - return results; - } - - // Try to shortcut find operations (as opposed to filters) in HTML documents - if ( !seed ) { - setDocument( context ); - context = context || document; - - if ( documentIsHTML ) { - - // If the selector is sufficiently simple, try using a "get*By*" DOM method - // (excepting DocumentFragment context, where the methods don't exist) - if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { - - // ID selector - if ( ( m = match[ 1 ] ) ) { - - // Document context - if ( nodeType === 9 ) { - if ( ( elem = context.getElementById( m ) ) ) { - - // Support: IE, Opera, Webkit - // TODO: identify versions - // getElementById can match elements by name instead of ID - if ( elem.id === m ) { - results.push( elem ); - return results; - } - } else { - return results; - } - - // Element context - } else { - - // Support: IE, Opera, Webkit - // TODO: identify versions - // getElementById can match elements by name instead of ID - if ( newContext && ( elem = newContext.getElementById( m ) ) && - contains( context, elem ) && - elem.id === m ) { - - results.push( elem ); - return results; - } - } - - // Type selector - } else if ( match[ 2 ] ) { - push.apply( results, context.getElementsByTagName( selector ) ); - return results; - - // Class selector - } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && - context.getElementsByClassName ) { - - push.apply( results, context.getElementsByClassName( m ) ); - return results; - } - } - - // Take advantage of querySelectorAll - if ( support.qsa && - !nonnativeSelectorCache[ selector + " " ] && - ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && - - // Support: IE 8 only - // Exclude object elements - ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { - - newSelector = selector; - newContext = context; - - // qSA considers elements outside a scoping root when evaluating child or - // descendant combinators, which is not what we want. - // In such cases, we work around the behavior by prefixing every selector in the - // list with an ID selector referencing the scope context. - // The technique has to be used as well when a leading combinator is used - // as such selectors are not recognized by querySelectorAll. - // Thanks to Andrew Dupont for this technique. - if ( nodeType === 1 && - ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { - - // Expand context for sibling selectors - newContext = rsibling.test( selector ) && testContext( context.parentNode ) || - context; - - // We can use :scope instead of the ID hack if the browser - // supports it & if we're not changing the context. - if ( newContext !== context || !support.scope ) { - - // Capture the context ID, setting it first if necessary - if ( ( nid = context.getAttribute( "id" ) ) ) { - nid = nid.replace( rcssescape, fcssescape ); - } else { - context.setAttribute( "id", ( nid = expando ) ); - } - } - - // Prefix every selector in the list - groups = tokenize( selector ); - i = groups.length; - while ( i-- ) { - groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + - toSelector( groups[ i ] ); - } - newSelector = groups.join( "," ); - } - - try { - push.apply( results, - newContext.querySelectorAll( newSelector ) - ); - return results; - } catch ( qsaError ) { - nonnativeSelectorCache( selector, true ); - } finally { - if ( nid === expando ) { - context.removeAttribute( "id" ); - } - } - } - } - } - - // All others - return select( selector.replace( rtrim, "$1" ), context, results, seed ); -} - -/** - * Create key-value caches of limited size - * @returns {function(string, object)} Returns the Object data after storing it on itself with - * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) - * deleting the oldest entry - */ -function createCache() { - var keys = []; - - function cache( key, value ) { - - // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) - if ( keys.push( key + " " ) > Expr.cacheLength ) { - - // Only keep the most recent entries - delete cache[ keys.shift() ]; - } - return ( cache[ key + " " ] = value ); - } - return cache; -} - -/** - * Mark a function for special use by Sizzle - * @param {Function} fn The function to mark - */ -function markFunction( fn ) { - fn[ expando ] = true; - return fn; -} - -/** - * Support testing using an element - * @param {Function} fn Passed the created element and returns a boolean result - */ -function assert( fn ) { - var el = document.createElement( "fieldset" ); - - try { - return !!fn( el ); - } catch ( e ) { - return false; - } finally { - - // Remove from its parent by default - if ( el.parentNode ) { - el.parentNode.removeChild( el ); - } - - // release memory in IE - el = null; - } -} - -/** - * Adds the same handler for all of the specified attrs - * @param {String} attrs Pipe-separated list of attributes - * @param {Function} handler The method that will be applied - */ -function addHandle( attrs, handler ) { - var arr = attrs.split( "|" ), - i = arr.length; - - while ( i-- ) { - Expr.attrHandle[ arr[ i ] ] = handler; - } -} - -/** - * Checks document order of two siblings - * @param {Element} a - * @param {Element} b - * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b - */ -function siblingCheck( a, b ) { - var cur = b && a, - diff = cur && a.nodeType === 1 && b.nodeType === 1 && - a.sourceIndex - b.sourceIndex; - - // Use IE sourceIndex if available on both nodes - if ( diff ) { - return diff; - } - - // Check if b follows a - if ( cur ) { - while ( ( cur = cur.nextSibling ) ) { - if ( cur === b ) { - return -1; - } - } - } - - return a ? 1 : -1; -} - -/** - * Returns a function to use in pseudos for input types - * @param {String} type - */ -function createInputPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for buttons - * @param {String} type - */ -function createButtonPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return ( name === "input" || name === "button" ) && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for :enabled/:disabled - * @param {Boolean} disabled true for :disabled; false for :enabled - */ -function createDisabledPseudo( disabled ) { - - // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable - return function( elem ) { - - // Only certain elements can match :enabled or :disabled - // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled - // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled - if ( "form" in elem ) { - - // Check for inherited disabledness on relevant non-disabled elements: - // * listed form-associated elements in a disabled fieldset - // https://html.spec.whatwg.org/multipage/forms.html#category-listed - // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled - // * option elements in a disabled optgroup - // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled - // All such elements have a "form" property. - if ( elem.parentNode && elem.disabled === false ) { - - // Option elements defer to a parent optgroup if present - if ( "label" in elem ) { - if ( "label" in elem.parentNode ) { - return elem.parentNode.disabled === disabled; - } else { - return elem.disabled === disabled; - } - } - - // Support: IE 6 - 11 - // Use the isDisabled shortcut property to check for disabled fieldset ancestors - return elem.isDisabled === disabled || - - // Where there is no isDisabled, check manually - /* jshint -W018 */ - elem.isDisabled !== !disabled && - inDisabledFieldset( elem ) === disabled; - } - - return elem.disabled === disabled; - - // Try to winnow out elements that can't be disabled before trusting the disabled property. - // Some victims get caught in our net (label, legend, menu, track), but it shouldn't - // even exist on them, let alone have a boolean value. - } else if ( "label" in elem ) { - return elem.disabled === disabled; - } - - // Remaining elements are neither :enabled nor :disabled - return false; - }; -} - -/** - * Returns a function to use in pseudos for positionals - * @param {Function} fn - */ -function createPositionalPseudo( fn ) { - return markFunction( function( argument ) { - argument = +argument; - return markFunction( function( seed, matches ) { - var j, - matchIndexes = fn( [], seed.length, argument ), - i = matchIndexes.length; - - // Match elements found at the specified indexes - while ( i-- ) { - if ( seed[ ( j = matchIndexes[ i ] ) ] ) { - seed[ j ] = !( matches[ j ] = seed[ j ] ); - } - } - } ); - } ); -} - -/** - * Checks a node for validity as a Sizzle context - * @param {Element|Object=} context - * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value - */ -function testContext( context ) { - return context && typeof context.getElementsByTagName !== "undefined" && context; -} - -// Expose support vars for convenience -support = Sizzle.support = {}; - -/** - * Detects XML nodes - * @param {Element|Object} elem An element or a document - * @returns {Boolean} True iff elem is a non-HTML XML node - */ -isXML = Sizzle.isXML = function( elem ) { - var namespace = elem && elem.namespaceURI, - docElem = elem && ( elem.ownerDocument || elem ).documentElement; - - // Support: IE <=8 - // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes - // https://bugs.jquery.com/ticket/4833 - return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); -}; - -/** - * Sets document-related variables once based on the current document - * @param {Element|Object} [doc] An element or document object to use to set the document - * @returns {Object} Returns the current document - */ -setDocument = Sizzle.setDocument = function( node ) { - var hasCompare, subWindow, - doc = node ? node.ownerDocument || node : preferredDoc; - - // Return early if doc is invalid or already selected - // Support: IE 11+, Edge 17 - 18+ - // IE/Edge sometimes throw a "Permission denied" error when strict-comparing - // two documents; shallow comparisons work. - // eslint-disable-next-line eqeqeq - if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { - return document; - } - - // Update global variables - document = doc; - docElem = document.documentElement; - documentIsHTML = !isXML( document ); - - // Support: IE 9 - 11+, Edge 12 - 18+ - // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) - // Support: IE 11+, Edge 17 - 18+ - // IE/Edge sometimes throw a "Permission denied" error when strict-comparing - // two documents; shallow comparisons work. - // eslint-disable-next-line eqeqeq - if ( preferredDoc != document && - ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { - - // Support: IE 11, Edge - if ( subWindow.addEventListener ) { - subWindow.addEventListener( "unload", unloadHandler, false ); - - // Support: IE 9 - 10 only - } else if ( subWindow.attachEvent ) { - subWindow.attachEvent( "onunload", unloadHandler ); - } - } - - // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, - // Safari 4 - 5 only, Opera <=11.6 - 12.x only - // IE/Edge & older browsers don't support the :scope pseudo-class. - // Support: Safari 6.0 only - // Safari 6.0 supports :scope but it's an alias of :root there. - support.scope = assert( function( el ) { - docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); - return typeof el.querySelectorAll !== "undefined" && - !el.querySelectorAll( ":scope fieldset div" ).length; - } ); - - // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ - // Make sure the the `:has()` argument is parsed unforgivingly. - // We include `*` in the test to detect buggy implementations that are - // _selectively_ forgiving (specifically when the list includes at least - // one valid selector). - // Note that we treat complete lack of support for `:has()` as if it were - // spec-compliant support, which is fine because use of `:has()` in such - // environments will fail in the qSA path and fall back to jQuery traversal - // anyway. - support.cssHas = assert( function() { - try { - document.querySelector( ":has(*,:jqfake)" ); - return false; - } catch ( e ) { - return true; - } - } ); - - /* Attributes - ---------------------------------------------------------------------- */ - - // Support: IE<8 - // Verify that getAttribute really returns attributes and not properties - // (excepting IE8 booleans) - support.attributes = assert( function( el ) { - el.className = "i"; - return !el.getAttribute( "className" ); - } ); - - /* getElement(s)By* - ---------------------------------------------------------------------- */ - - // Check if getElementsByTagName("*") returns only elements - support.getElementsByTagName = assert( function( el ) { - el.appendChild( document.createComment( "" ) ); - return !el.getElementsByTagName( "*" ).length; - } ); - - // Support: IE<9 - support.getElementsByClassName = rnative.test( document.getElementsByClassName ); - - // Support: IE<10 - // Check if getElementById returns elements by name - // The broken getElementById methods don't pick up programmatically-set names, - // so use a roundabout getElementsByName test - support.getById = assert( function( el ) { - docElem.appendChild( el ).id = expando; - return !document.getElementsByName || !document.getElementsByName( expando ).length; - } ); - - // ID filter and find - if ( support.getById ) { - Expr.filter[ "ID" ] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - return elem.getAttribute( "id" ) === attrId; - }; - }; - Expr.find[ "ID" ] = function( id, context ) { - if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { - var elem = context.getElementById( id ); - return elem ? [ elem ] : []; - } - }; - } else { - Expr.filter[ "ID" ] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - var node = typeof elem.getAttributeNode !== "undefined" && - elem.getAttributeNode( "id" ); - return node && node.value === attrId; - }; - }; - - // Support: IE 6 - 7 only - // getElementById is not reliable as a find shortcut - Expr.find[ "ID" ] = function( id, context ) { - if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { - var node, i, elems, - elem = context.getElementById( id ); - - if ( elem ) { - - // Verify the id attribute - node = elem.getAttributeNode( "id" ); - if ( node && node.value === id ) { - return [ elem ]; - } - - // Fall back on getElementsByName - elems = context.getElementsByName( id ); - i = 0; - while ( ( elem = elems[ i++ ] ) ) { - node = elem.getAttributeNode( "id" ); - if ( node && node.value === id ) { - return [ elem ]; - } - } - } - - return []; - } - }; - } - - // Tag - Expr.find[ "TAG" ] = support.getElementsByTagName ? - function( tag, context ) { - if ( typeof context.getElementsByTagName !== "undefined" ) { - return context.getElementsByTagName( tag ); - - // DocumentFragment nodes don't have gEBTN - } else if ( support.qsa ) { - return context.querySelectorAll( tag ); - } - } : - - function( tag, context ) { - var elem, - tmp = [], - i = 0, - - // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too - results = context.getElementsByTagName( tag ); - - // Filter out possible comments - if ( tag === "*" ) { - while ( ( elem = results[ i++ ] ) ) { - if ( elem.nodeType === 1 ) { - tmp.push( elem ); - } - } - - return tmp; - } - return results; - }; - - // Class - Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { - if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { - return context.getElementsByClassName( className ); - } - }; - - /* QSA/matchesSelector - ---------------------------------------------------------------------- */ - - // QSA and matchesSelector support - - // matchesSelector(:active) reports false when true (IE9/Opera 11.5) - rbuggyMatches = []; - - // qSa(:focus) reports false when true (Chrome 21) - // We allow this because of a bug in IE8/9 that throws an error - // whenever `document.activeElement` is accessed on an iframe - // So, we allow :focus to pass through QSA all the time to avoid the IE error - // See https://bugs.jquery.com/ticket/13378 - rbuggyQSA = []; - - if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { - - // Build QSA regex - // Regex strategy adopted from Diego Perini - assert( function( el ) { - - var input; - - // Select is set to empty string on purpose - // This is to test IE's treatment of not explicitly - // setting a boolean content attribute, - // since its presence should be enough - // https://bugs.jquery.com/ticket/12359 - docElem.appendChild( el ).innerHTML = "" + - ""; - - // Support: IE8, Opera 11-12.16 - // Nothing should be selected when empty strings follow ^= or $= or *= - // The test attribute must be unknown in Opera but "safe" for WinRT - // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section - if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { - rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); - } - - // Support: IE8 - // Boolean attributes and "value" are not treated correctly - if ( !el.querySelectorAll( "[selected]" ).length ) { - rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); - } - - // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ - if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { - rbuggyQSA.push( "~=" ); - } - - // Support: IE 11+, Edge 15 - 18+ - // IE 11/Edge don't find elements on a `[name='']` query in some cases. - // Adding a temporary attribute to the document before the selection works - // around the issue. - // Interestingly, IE 10 & older don't seem to have the issue. - input = document.createElement( "input" ); - input.setAttribute( "name", "" ); - el.appendChild( input ); - if ( !el.querySelectorAll( "[name='']" ).length ) { - rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + - whitespace + "*(?:''|\"\")" ); - } - - // Webkit/Opera - :checked should return selected option elements - // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked - // IE8 throws error here and will not see later tests - if ( !el.querySelectorAll( ":checked" ).length ) { - rbuggyQSA.push( ":checked" ); - } - - // Support: Safari 8+, iOS 8+ - // https://bugs.webkit.org/show_bug.cgi?id=136851 - // In-page `selector#id sibling-combinator selector` fails - if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { - rbuggyQSA.push( ".#.+[+~]" ); - } - - // Support: Firefox <=3.6 - 5 only - // Old Firefox doesn't throw on a badly-escaped identifier. - el.querySelectorAll( "\\\f" ); - rbuggyQSA.push( "[\\r\\n\\f]" ); - } ); - - assert( function( el ) { - el.innerHTML = "" + - ""; - - // Support: Windows 8 Native Apps - // The type and name attributes are restricted during .innerHTML assignment - var input = document.createElement( "input" ); - input.setAttribute( "type", "hidden" ); - el.appendChild( input ).setAttribute( "name", "D" ); - - // Support: IE8 - // Enforce case-sensitivity of name attribute - if ( el.querySelectorAll( "[name=d]" ).length ) { - rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); - } - - // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) - // IE8 throws error here and will not see later tests - if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { - rbuggyQSA.push( ":enabled", ":disabled" ); - } - - // Support: IE9-11+ - // IE's :disabled selector does not pick up the children of disabled fieldsets - docElem.appendChild( el ).disabled = true; - if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { - rbuggyQSA.push( ":enabled", ":disabled" ); - } - - // Support: Opera 10 - 11 only - // Opera 10-11 does not throw on post-comma invalid pseudos - el.querySelectorAll( "*,:x" ); - rbuggyQSA.push( ",.*:" ); - } ); - } - - if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || - docElem.webkitMatchesSelector || - docElem.mozMatchesSelector || - docElem.oMatchesSelector || - docElem.msMatchesSelector ) ) ) ) { - - assert( function( el ) { - - // Check to see if it's possible to do matchesSelector - // on a disconnected node (IE 9) - support.disconnectedMatch = matches.call( el, "*" ); - - // This should fail with an exception - // Gecko does not error, returns false instead - matches.call( el, "[s!='']:x" ); - rbuggyMatches.push( "!=", pseudos ); - } ); - } - - if ( !support.cssHas ) { - - // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ - // Our regular `try-catch` mechanism fails to detect natively-unsupported - // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) - // in browsers that parse the `:has()` argument as a forgiving selector list. - // https://drafts.csswg.org/selectors/#relational now requires the argument - // to be parsed unforgivingly, but browsers have not yet fully adjusted. - rbuggyQSA.push( ":has" ); - } - - rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); - rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); - - /* Contains - ---------------------------------------------------------------------- */ - hasCompare = rnative.test( docElem.compareDocumentPosition ); - - // Element contains another - // Purposefully self-exclusive - // As in, an element does not contain itself - contains = hasCompare || rnative.test( docElem.contains ) ? - function( a, b ) { - - // Support: IE <9 only - // IE doesn't have `contains` on `document` so we need to check for - // `documentElement` presence. - // We need to fall back to `a` when `documentElement` is missing - // as `ownerDocument` of elements within `