diff --git a/programmer/admin.py b/programmer/admin.py index e4128e6..f5055c7 100644 --- a/programmer/admin.py +++ b/programmer/admin.py @@ -3,41 +3,45 @@ from django.utils import timezone from django.utils.html import format_html from django.urls import path from django.shortcuts import render -from django.http import HttpResponseRedirect from django.contrib import messages -from .models import * + +from .models import Home, Solution, Competence, Recall, CallbackRequest, PageView -class ProgrammerAdmin(admin.ModelAdmin): - list_display = ('id', 'title', 'time_create', 'photo', 'is_published') +# ===== BASE ADMIN CLASSES ===== + +class BaseContentAdmin(admin.ModelAdmin): + """Общий базовый класс для контентных моделей.""" list_display_links = ('id', 'title') - search_fields = ('title', 'content') list_editable = ('is_published',) list_filter = ('time_create', 'is_published') + search_fields = ('title',) -class RecallAdmin(admin.ModelAdmin): - 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') +# ===== MODEL ADMINS ===== - -class SolutionAdmin(admin.ModelAdmin): +@admin.register(Home) +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_links = ('id', 'title') 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_links = ('id', 'title') 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) @@ -48,61 +52,53 @@ class CallbackAdmin(admin.ModelAdmin): list_filter = ('time_create', 'is_processed', 'is_read') search_fields = ('name', 'phone', 'email') 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'] + # ── badge helper ────────────────────────────────────────────────────────── + + def new_badge(self, obj): + if not obj.is_read: + return format_html('🆕 НОВАЯ') + 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): from .utils.email_notifications import send_multiple_callback_notifications count = send_multiple_callback_notifications(queryset) 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('🆕 НОВАЯ') - 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): urls = super().get_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 - def callback_stats(self, request): + def callback_stats_view(self, request): today = timezone.now().date() week_ago = today - timezone.timedelta(days=7) @@ -121,19 +117,26 @@ class CallbackAdmin(admin.ModelAdmin): } 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) class PageViewAdmin(admin.ModelAdmin): - list_display = ['url', 'timestamp', 'ip_address'] - list_filter = ['timestamp', 'url'] - search_fields = ['url', 'ip_address'] + list_display = ('url', 'timestamp', 'ip_address') + list_filter = ('timestamp', 'url') + search_fields = ('url', 'ip_address') date_hierarchy = 'timestamp' + readonly_fields = ('url', 'timestamp', 'ip_address') def get_queryset(self, request): return super().get_queryset(request).order_by('-timestamp') -admin.site.register(Competence, ProgrammerAdmin) -admin.site.register(Recall, RecallAdmin) -admin.site.register(Solution, SolutionAdmin) -admin.site.register(Home, HomeAdmin) + # PageViews should never be created or edited manually + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/programmer/static/blog/css/article.css b/programmer/static/blog/css/article.css index d4c17af..bf3fdac 100644 --- a/programmer/static/blog/css/article.css +++ b/programmer/static/blog/css/article.css @@ -1,90 +1,446 @@ -/* Стили для содержимого статьи */ -.article-content { - font-size: 1rem; - line-height: 1.6; +/* + * article.css + * Styles for CKEditor 5 rendered content inside .content-card + * 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); } -.article-content h1, -.article-content h2, -.article-content h3, -.article-content h4, -.article-content h5, -.article-content h6 { - margin-top: 1.5em; - margin-bottom: 0.75em; - font-weight: 600; +.content-card .card-content h2 { + font-size: 1.75rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--border-light); } -.article-content h1 { font-size: 2rem; } -.article-content h2 { font-size: 1.75rem; } -.article-content h3 { font-size: 1.5rem; } - -.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 { +.content-card .card-content h3 { + font-size: 1.375rem; + padding-left: 0.75rem; 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
) --- */
+
+.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%;
border-collapse: collapse;
- margin: 1rem 0;
+ font-size: 0.95rem;
}
-.article-content th,
-.article-content td {
- border: 1px solid var(--border-light);
- padding: 0.5rem;
+.content-card .card-content th {
+ background: var(--bg-secondary);
+ font-weight: 700;
+ 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);
}
-.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 ) --- */
+
+.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%;
height: auto;
- margin: 1rem 0;
- border-radius: 4px;
+ display: block;
+ margin: 0 auto;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ cursor: pointer; /* triggers the click-to-enlarge modal */
}
-.article-content pre {
- background: #2d2d2d;
- color: #ccc;
- padding: 1rem;
- border-radius: 4px;
- overflow-x: auto;
- margin: 1rem 0;
+.content-card .card-content figure.image figcaption {
+ margin-top: 0.5rem;
+ font-size: 0.875rem;
+ color: var(--text-light);
+ font-style: italic;
+ text-align: center;
}
-.article-content code {
- font-family: 'Courier New', monospace;
- background: #2d2d2d;
- color: #ccc;
- padding: 0.2rem 0.4rem;
- border-radius: 3px;
- font-size: 0.9em;
+/* CKEditor alignment classes */
+.content-card .card-content figure.image.image-style-align-left {
+ float: left;
+ margin: 0.5rem 1.5rem 1rem 0;
+ max-width: 45%;
}
-.article-content pre code {
- padding: 0;
- background: none;
- color: inherit;
+.content-card .card-content figure.image.image-style-align-right {
+ float: right;
+ margin: 0.5rem 0 1rem 1.5rem;
+ 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;
+ }
}
\ No newline at end of file