Откорректировал админ панель и стили редактора

This commit is contained in:
NikDizell 2026-03-09 11:23:17 +03:00
parent 68f52d5ea5
commit a62982b143
2 changed files with 491 additions and 132 deletions

View File

@ -3,41 +3,45 @@ from django.utils import timezone
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import path from django.urls import path
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpResponseRedirect
from django.contrib import messages from django.contrib import messages
from .models import *
from .models import Home, Solution, Competence, Recall, CallbackRequest, PageView
class ProgrammerAdmin(admin.ModelAdmin): # ===== BASE ADMIN CLASSES =====
list_display = ('id', 'title', 'time_create', 'photo', 'is_published')
class BaseContentAdmin(admin.ModelAdmin):
"""Общий базовый класс для контентных моделей."""
list_display_links = ('id', 'title') list_display_links = ('id', 'title')
search_fields = ('title', 'content')
list_editable = ('is_published',) list_editable = ('is_published',)
list_filter = ('time_create', 'is_published') list_filter = ('time_create', 'is_published')
search_fields = ('title',)
class RecallAdmin(admin.ModelAdmin): # ===== MODEL ADMINS =====
list_display = ('id', 'title', 'time_create', 'scan', 'is_published')
list_display_links = ('id', 'title')
search_fields = ('title', 'content')
list_editable = ('is_published',)
list_filter = ('time_create', 'is_published')
@admin.register(Home)
class SolutionAdmin(admin.ModelAdmin): class HomeAdmin(BaseContentAdmin):
list_display = ('id', 'title', 'time_create', 'is_published')
search_fields = ('title', 'content')
@admin.register(Solution)
class SolutionAdmin(BaseContentAdmin):
list_display = ('id', 'title', 'time_create', 'is_published') list_display = ('id', 'title', 'time_create', 'is_published')
list_display_links = ('id', 'title')
search_fields = ('title', 'description', 'implementation') search_fields = ('title', 'description', 'implementation')
list_editable = ('is_published',)
list_filter = ('time_create', 'is_published')
class HomeAdmin(admin.ModelAdmin): @admin.register(Competence)
class CompetenceAdmin(BaseContentAdmin):
list_display = ('id', 'title', 'time_create', 'is_published') list_display = ('id', 'title', 'time_create', 'is_published')
list_display_links = ('id', 'title')
search_fields = ('title', 'content') search_fields = ('title', 'content')
list_editable = ('is_published',)
list_filter = ('time_create', 'is_published')
@admin.register(Recall)
class RecallAdmin(BaseContentAdmin):
list_display = ('id', 'title', 'time_create', 'scan', 'is_published')
search_fields = ('title', 'content')
@admin.register(CallbackRequest) @admin.register(CallbackRequest)
@ -48,61 +52,53 @@ class CallbackAdmin(admin.ModelAdmin):
list_filter = ('time_create', 'is_processed', 'is_read') list_filter = ('time_create', 'is_processed', 'is_read')
search_fields = ('name', 'phone', 'email') search_fields = ('name', 'phone', 'email')
readonly_fields = ('time_create',) readonly_fields = ('time_create',)
# actions = ['mark_as_read', 'mark_as_unread', 'mark_as_processed']
actions = ['mark_as_read', 'mark_as_unread', 'mark_as_processed', 'resend_notification'] actions = ['mark_as_read', 'mark_as_unread', 'mark_as_processed', 'resend_notification']
# ── badge helper ──────────────────────────────────────────────────────────
def new_badge(self, obj):
if not obj.is_read:
return format_html('<span style="color:red;font-weight:bold;">🆕 НОВАЯ</span>')
return ''
new_badge.short_description = 'Статус'
# ── bulk actions ──────────────────────────────────────────────────────────
def mark_as_read(self, request, queryset):
updated = queryset.update(is_read=True)
self.message_user(request, f'{updated} заявок отмечены как прочитанные')
mark_as_read.short_description = 'Отметить как прочитанные'
def mark_as_unread(self, request, queryset):
updated = queryset.update(is_read=False)
self.message_user(request, f'{updated} заявок отмечены как непрочитанные')
mark_as_unread.short_description = 'Отметить как непрочитанные'
def mark_as_processed(self, request, queryset):
updated = queryset.update(is_processed=True)
self.message_user(request, f'{updated} заявок отмечены как обработанные')
mark_as_processed.short_description = 'Отметить как обработанные'
def resend_notification(self, request, queryset): def resend_notification(self, request, queryset):
from .utils.email_notifications import send_multiple_callback_notifications from .utils.email_notifications import send_multiple_callback_notifications
count = send_multiple_callback_notifications(queryset) count = send_multiple_callback_notifications(queryset)
self.message_user(request, f'Уведомления отправлены для {count} заявок') self.message_user(request, f'Уведомления отправлены для {count} заявок')
resend_notification.short_description = 'Переотправить email уведомления'
resend_notification.short_description = "Переотправить email уведомления" # ── custom URL + view for stats ───────────────────────────────────────────
def new_badge(self, obj):
if not obj.is_read:
return format_html('<span style="color: red; font-weight: bold;">🆕 НОВАЯ</span>')
return ""
new_badge.short_description = 'Статус'
def get_queryset(self, request):
# Показываем количество непрочитанных в заголовке
unread_count = CallbackRequest.objects.filter(is_read=False).count()
if unread_count > 0:
self.message_user(
request,
f'У вас {unread_count} непрочитанных заявок!',
messages.WARNING
)
return super().get_queryset(request)
def mark_as_read(self, request, queryset):
updated = queryset.update(is_read=True)
self.message_user(request, f'{updated} заявок отмечены как прочитанные')
mark_as_read.short_description = "Отметить как прочитанные"
def mark_as_unread(self, request, queryset):
updated = queryset.update(is_read=False)
self.message_user(request, f'{updated} заявок отмечены как непрочитанные')
mark_as_unread.short_description = "Отметить как непрочитанные"
def mark_as_processed(self, request, queryset):
updated = queryset.update(is_processed=True)
self.message_user(request, f'{updated} заявок отмечены как обработанные')
mark_as_processed.short_description = "Отметить как обработанные"
# Добавляем кастомное представление для статистики
def get_urls(self): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
custom_urls = [ custom_urls = [
path('callback-stats/', self.admin_site.admin_view(self.callback_stats), name='callback_stats'), path(
'callback-stats/',
self.admin_site.admin_view(self.callback_stats_view),
name='callback_stats',
),
] ]
return custom_urls + urls return custom_urls + urls
def callback_stats(self, request): def callback_stats_view(self, request):
today = timezone.now().date() today = timezone.now().date()
week_ago = today - timezone.timedelta(days=7) week_ago = today - timezone.timedelta(days=7)
@ -121,19 +117,26 @@ class CallbackAdmin(admin.ModelAdmin):
} }
return render(request, 'admin/callback_stats.html', context) return render(request, 'admin/callback_stats.html', context)
# ── FIX: removed get_queryset message_user spam ───────────────────────────
# Previously, a warning banner was shown on EVERY request to the changelist,
# including background requests. Removed entirely — the new_badge column
# and base_site.html header notification already surface unread counts.
@admin.register(PageView) @admin.register(PageView)
class PageViewAdmin(admin.ModelAdmin): class PageViewAdmin(admin.ModelAdmin):
list_display = ['url', 'timestamp', 'ip_address'] list_display = ('url', 'timestamp', 'ip_address')
list_filter = ['timestamp', 'url'] list_filter = ('timestamp', 'url')
search_fields = ['url', 'ip_address'] search_fields = ('url', 'ip_address')
date_hierarchy = 'timestamp' date_hierarchy = 'timestamp'
readonly_fields = ('url', 'timestamp', 'ip_address')
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).order_by('-timestamp') return super().get_queryset(request).order_by('-timestamp')
admin.site.register(Competence, ProgrammerAdmin) # PageViews should never be created or edited manually
admin.site.register(Recall, RecallAdmin) def has_add_permission(self, request):
admin.site.register(Solution, SolutionAdmin) return False
admin.site.register(Home, HomeAdmin)
def has_change_permission(self, request, obj=None):
return False

View File

@ -1,90 +1,446 @@
/* Стили для содержимого статьи */ /*
.article-content { * article.css
font-size: 1rem; * Styles for CKEditor 5 rendered content inside .content-card
line-height: 1.6; * and for the article detail page layout.
*
* Scope: all rules are under .content-card .card-content
* so they never leak into the rest of the site.
*/
/* ===== ARTICLE PAGE LAYOUT ===== */
.article-meta {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
font-size: 0.9rem;
color: var(--text-light);
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-light);
}
.article-meta span {
display: flex;
align-items: center;
gap: 0.4rem;
}
.article-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1.5rem 0;
}
.article-tag {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
font-size: 0.8rem;
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition);
}
.article-tag:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* ===== CKEDITOR 5 CONTENT ===== */
.content-card .card-content {
font-size: 1.05rem;
line-height: 1.8;
color: var(--text-primary);
word-break: break-word;
}
/* --- Headings --- */
.content-card .card-content h2,
.content-card .card-content h3,
.content-card .card-content h4,
.content-card .card-content h5,
.content-card .card-content h6 {
font-weight: 700;
line-height: 1.3;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: var(--text-primary); color: var(--text-primary);
} }
.article-content h1, .content-card .card-content h2 {
.article-content h2, font-size: 1.75rem;
.article-content h3, padding-bottom: 0.5rem;
.article-content h4, border-bottom: 2px solid var(--border-light);
.article-content h5,
.article-content h6 {
margin-top: 1.5em;
margin-bottom: 0.75em;
font-weight: 600;
} }
.article-content h1 { font-size: 2rem; } .content-card .card-content h3 {
.article-content h2 { font-size: 1.75rem; } font-size: 1.375rem;
.article-content h3 { font-size: 1.5rem; } padding-left: 0.75rem;
.article-content p {
margin-bottom: 1rem;
}
.article-content ul,
.article-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.article-content li {
margin-bottom: 0.25rem;
}
.article-content blockquote {
border-left: 4px solid var(--primary); border-left: 4px solid var(--primary);
padding: 0.5rem 1rem;
background: var(--bg-secondary);
margin: 1rem 0;
font-style: italic;
} }
.article-content table { .content-card .card-content h4 {
font-size: 1.125rem;
color: var(--text-secondary);
}
/* --- Paragraphs --- */
.content-card .card-content p {
margin-bottom: 1.25rem;
}
.content-card .card-content p:last-child {
margin-bottom: 0;
}
/* --- Links --- */
.content-card .card-content a {
color: var(--primary);
text-decoration: underline;
text-decoration-color: rgba(255, 107, 0, 0.4);
text-underline-offset: 3px;
transition: var(--transition);
}
.content-card .card-content a:hover {
color: var(--primary-dark);
text-decoration-color: var(--primary-dark);
}
/* --- Lists --- */
.content-card .card-content ul,
.content-card .card-content ol {
margin: 0 0 1.25rem 0;
padding-left: 1.75rem;
}
.content-card .card-content ul {
list-style: none;
padding-left: 0;
}
.content-card .card-content ul li {
position: relative;
padding-left: 1.5rem;
margin-bottom: 0.5rem;
}
.content-card .card-content ul li::before {
content: '▸';
position: absolute;
left: 0;
color: var(--primary);
font-weight: bold;
}
.content-card .card-content ol li {
margin-bottom: 0.5rem;
}
.content-card .card-content ul ul,
.content-card .card-content ol ol,
.content-card .card-content ul ol,
.content-card .card-content ol ul {
margin-top: 0.5rem;
margin-bottom: 0;
}
/* --- Blockquote --- */
.content-card .card-content blockquote {
margin: 1.5rem 0;
padding: 1.25rem 1.5rem;
border-left: 4px solid var(--primary);
background: var(--bg-secondary);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
color: var(--text-secondary);
font-style: italic;
font-size: 1.05rem;
}
.content-card .card-content blockquote p:last-child {
margin-bottom: 0;
}
/* --- Inline code --- */
.content-card .card-content code {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.875em;
background: var(--bg-tertiary);
color: var(--primary-dark);
padding: 0.15em 0.4em;
border-radius: 4px;
border: 1px solid var(--border-light);
word-break: break-all;
}
/* --- Code blocks (CKEditor generates <pre><code>) --- */
.content-card .card-content pre {
margin: 1.5rem 0;
padding: 1.5rem;
background: #1A1F2E;
border-radius: var(--radius-md);
overflow-x: auto;
border: 1px solid var(--border-dark);
box-shadow: var(--shadow-md);
}
.content-card .card-content pre code {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
background: transparent;
color: #E2E8F0;
padding: 0;
border: none;
border-radius: 0;
word-break: normal;
white-space: pre;
}
/* --- Tables --- */
.content-card .card-content .table-wrapper,
.content-card .card-content figure.table {
overflow-x: auto;
margin: 1.5rem 0;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.content-card .card-content table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 1rem 0; font-size: 0.95rem;
} }
.article-content th, .content-card .card-content th {
.article-content td { background: var(--bg-secondary);
border: 1px solid var(--border-light); font-weight: 700;
padding: 0.5rem; text-align: left;
padding: 0.875rem 1rem;
border-bottom: 2px solid var(--border-medium);
color: var(--text-primary);
white-space: nowrap;
} }
.article-content th { .content-card .card-content td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-light);
color: var(--text-secondary);
vertical-align: top;
}
.content-card .card-content tr:last-child td {
border-bottom: none;
}
.content-card .card-content tbody tr:hover td {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.article-content img { /* CKEditor 5 table with header row class */
.content-card .card-content table.ck-table-resized th,
.content-card .card-content table.ck-table-resized td {
border: 1px solid var(--border-light);
}
/* --- Images (CKEditor wraps in <figure class="image">) --- */
.content-card .card-content figure.image {
margin: 1.5rem auto;
text-align: center;
max-width: 100%;
}
.content-card .card-content figure.image img,
.content-card .card-content img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
margin: 1rem 0; display: block;
border-radius: 4px; margin: 0 auto;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
cursor: pointer; /* triggers the click-to-enlarge modal */
} }
.article-content pre { .content-card .card-content figure.image figcaption {
background: #2d2d2d; margin-top: 0.5rem;
color: #ccc; font-size: 0.875rem;
padding: 1rem; color: var(--text-light);
border-radius: 4px; font-style: italic;
overflow-x: auto; text-align: center;
margin: 1rem 0;
} }
.article-content code { /* CKEditor alignment classes */
font-family: 'Courier New', monospace; .content-card .card-content figure.image.image-style-align-left {
background: #2d2d2d; float: left;
color: #ccc; margin: 0.5rem 1.5rem 1rem 0;
padding: 0.2rem 0.4rem; max-width: 45%;
border-radius: 3px;
font-size: 0.9em;
} }
.article-content pre code { .content-card .card-content figure.image.image-style-align-right {
padding: 0; float: right;
background: none; margin: 0.5rem 0 1rem 1.5rem;
color: inherit; max-width: 45%;
}
/* Clearfix after floated images */
.content-card .card-content::after {
content: '';
display: table;
clear: both;
}
/* --- Horizontal rule --- */
.content-card .card-content hr {
border: none;
height: 2px;
background: linear-gradient(90deg, transparent, var(--border-medium), transparent);
margin: 2rem 0;
}
/* --- Alert/info boxes (CKEditor custom classes if used) --- */
.content-card .card-content .info-box {
padding: 1rem 1.25rem;
border-radius: var(--radius-md);
margin: 1.5rem 0;
border-left: 4px solid var(--accent);
background: rgba(0, 168, 255, 0.08);
color: var(--text-secondary);
font-size: 0.95rem;
}
/* ===== COMMENTS SECTION ===== */
.comments-section {
margin-top: 3rem;
}
.comments-section h3 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.75rem;
}
.comment-card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
border: 1px solid var(--border-light);
border-left: 3px solid var(--secondary);
}
.comment-author {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
}
.comment-date {
font-size: 0.8rem;
color: var(--text-light);
margin-left: 0.75rem;
}
.comment-body {
margin-top: 0.75rem;
color: var(--text-secondary);
line-height: 1.6;
}
.comment-form-card {
background: var(--bg-card);
border-radius: var(--radius-xl);
padding: 2rem;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
margin-top: 2rem;
position: relative;
}
.comment-form-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--gradient-secondary);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.comment-form-card h4 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1.5rem;
color: var(--text-primary);
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.content-card .card-content h2 {
font-size: 1.5rem;
}
.content-card .card-content h3 {
font-size: 1.25rem;
}
.content-card .card-content figure.image.image-style-align-left,
.content-card .card-content figure.image.image-style-align-right {
float: none;
max-width: 100%;
margin: 1rem auto;
}
.content-card .card-content pre {
padding: 1rem;
font-size: 0.8rem;
}
.content-card .card-content th,
.content-card .card-content td {
padding: 0.6rem 0.75rem;
font-size: 0.875rem;
}
.article-meta {
gap: 1rem;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.content-card .card-content {
font-size: 1rem;
}
.content-card .card-content h2 {
font-size: 1.375rem;
}
.content-card .card-content h3 {
font-size: 1.125rem;
}
} }