diff --git a/blog/templates/blog/article_detail.html b/blog/templates/blog/article_detail.html
index 37c8537..f66916b 100644
--- a/blog/templates/blog/article_detail.html
+++ b/blog/templates/blog/article_detail.html
@@ -60,15 +60,12 @@
{% endif %}
-
-
-
![]()
@@ -76,6 +73,9 @@
+
+
+
{% endblock %}
{% block extra_js %}
@@ -90,7 +90,7 @@
img.style.cursor = 'pointer';
img.addEventListener('click', function() {
// Вызываем глобальную функцию openModal из recall.js
- openModal(this.src, this.alt || 'Изображение из статьи');
+ ImageModal.open(this.src, this.alt || 'Изображение из статьи');
});
});
});
diff --git a/programmer/admin.py b/programmer/admin.py
index f5055c7..004a1c6 100644
--- a/programmer/admin.py
+++ b/programmer/admin.py
@@ -3,7 +3,8 @@ from django.utils import timezone
from django.utils.html import format_html
from django.urls import path
from django.shortcuts import render
-from django.contrib import messages
+from django.db import models
+from django_ckeditor_5.widgets import CKEditor5Widget
from .models import Home, Solution, Competence, Recall, CallbackRequest, PageView
@@ -11,38 +12,134 @@ from .models import Home, Solution, Competence, Recall, CallbackRequest, PageVie
# ===== BASE ADMIN CLASSES =====
class BaseContentAdmin(admin.ModelAdmin):
- """Общий базовый класс для контентных моделей."""
+ """
+ Базовый класс для контентных моделей.
+ CKEditor, actions публикации, date_hierarchy.
+ """
list_display_links = ('id', 'title')
list_editable = ('is_published',)
list_filter = ('time_create', 'is_published')
search_fields = ('title',)
+ date_hierarchy = 'time_create'
+ actions = ['make_published', 'make_unpublished']
+ readonly_fields = ('time_create', 'time_update')
+
+ formfield_overrides = {
+ models.TextField: {'widget': CKEditor5Widget(config_name='default')},
+ }
+
+ def make_published(self, request, queryset):
+ updated = queryset.update(is_published=True)
+ self.message_user(request, f'{updated} объектов опубликовано.')
+ make_published.short_description = 'Опубликовать выбранные'
+
+ def make_unpublished(self, request, queryset):
+ updated = queryset.update(is_published=False)
+ self.message_user(request, f'{updated} объектов снято с публикации.')
+ make_unpublished.short_description = 'Снять с публикации'
# ===== MODEL ADMINS =====
@admin.register(Home)
class HomeAdmin(BaseContentAdmin):
+ # Поля: title, content, home_image, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'is_published')
search_fields = ('title', 'content')
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'is_published'),
+ }),
+ ('Содержание', {
+ 'fields': ('content', 'home_image'),
+ 'description': 'Основной текст главной страницы.',
+ }),
+ ('Служебное', {
+ 'fields': ('time_create', 'time_update'),
+ 'classes': ('collapse',),
+ }),
+ )
+
@admin.register(Solution)
class SolutionAdmin(BaseContentAdmin):
+ # Поля: title, description, implementation, closing, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'is_published')
+ list_display_links = ('id', 'title')
search_fields = ('title', 'description', 'implementation')
+ prepopulated_fields = {'slug': ('title',)}
+
+
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'slug', 'is_published'),
+ }),
+ ('Содержание', {
+ 'fields': ('description', 'implementation', 'closing'),
+ 'description': 'Описание, реализация и заключение по проекту.',
+ }),
+ ('Служебное', {
+ 'fields': ('time_create', 'time_update'),
+ 'classes': ('collapse',),
+ }),
+ )
@admin.register(Competence)
class CompetenceAdmin(BaseContentAdmin):
+ # Поля: title, content, photo, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'is_published')
search_fields = ('title', 'content')
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'is_published'),
+ }),
+ ('Содержание', {
+ 'fields': ('content', 'photo'),
+ 'description': 'Описание компетенции и фото.',
+ }),
+ ('Служебное', {
+ 'fields': ('time_create', 'time_update'),
+ 'classes': ('collapse',),
+ }),
+ )
+
@admin.register(Recall)
class RecallAdmin(BaseContentAdmin):
- list_display = ('id', 'title', 'time_create', 'scan', 'is_published')
+ # Поля: title, content, scan, time_create, time_update, is_published
+ list_display = ('id', 'title', 'time_create', 'scan_preview', 'is_published')
search_fields = ('title', 'content')
+ fieldsets = (
+ (None, {
+ 'fields': ('title', 'is_published'),
+ }),
+ ('Содержание', {
+ 'fields': ('content', 'scan', 'scan_preview'),
+ 'description': 'Текст отзыва и скан документа.',
+ }),
+ ('Служебное', {
+ 'fields': ('time_create', 'time_update'),
+ 'classes': ('collapse',),
+ }),
+ )
+ readonly_fields = ('time_create', 'time_update', 'scan_preview')
+
+ def scan_preview(self, obj):
+ if obj.scan:
+ return format_html(
+ '

',
+ obj.scan.url
+ )
+ return '—'
+ scan_preview.short_description = 'Превью скана'
+
+
+# ===== CALLBACK =====
@admin.register(CallbackRequest)
class CallbackAdmin(admin.ModelAdmin):
@@ -54,16 +151,12 @@ class CallbackAdmin(admin.ModelAdmin):
readonly_fields = ('time_create',)
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} заявок отмечены как прочитанные')
@@ -85,8 +178,6 @@ class CallbackAdmin(admin.ModelAdmin):
self.message_user(request, f'Уведомления отправлены для {count} заявок')
resend_notification.short_description = 'Переотправить email уведомления'
- # ── custom URL + view for stats ───────────────────────────────────────────
-
def get_urls(self):
urls = super().get_urls()
custom_urls = [
@@ -117,11 +208,8 @@ 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.
+# ===== PAGE VIEWS =====
@admin.register(PageView)
class PageViewAdmin(admin.ModelAdmin):
@@ -134,9 +222,8 @@ class PageViewAdmin(admin.ModelAdmin):
def get_queryset(self, request):
return super().get_queryset(request).order_by('-timestamp')
- # 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
+ return False
\ No newline at end of file
diff --git a/programmer/forms.py b/programmer/forms.py
index a7fd81b..73c781d 100644
--- a/programmer/forms.py
+++ b/programmer/forms.py
@@ -78,6 +78,7 @@ class ProfileForm(forms.ModelForm):
class RegistrationForm(UserCreationForm):
+ captcha = TurnstileField(label='', theme='light', size='normal')
# Поля пользователя
first_name = forms.CharField(
max_length=30,
diff --git a/programmer/models.py b/programmer/models.py
index cc22f36..303c1bf 100644
--- a/programmer/models.py
+++ b/programmer/models.py
@@ -1,3 +1,5 @@
+import logging
+
from django.contrib.auth.models import User
from django.db import models
from django.urls import reverse
@@ -7,11 +9,13 @@ from django.dispatch import receiver
from django.core.cache import cache
from .utils.email_notifications import send_callback_notification
+logger = logging.getLogger(__name__)
+
class Recall(models.Model):
title = models.CharField(max_length=255, verbose_name='Организация')
content = models.TextField(blank=True, verbose_name='Отзыв')
- scan = models.ImageField(upload_to="scan/%Y/%m/%d/", verbose_name='Фото')
+ scan = models.ImageField(upload_to="scan/%Y/%m/%d/", blank=True, null=True, verbose_name='Фото')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@@ -40,7 +44,7 @@ class Recall(models.Model):
class Competence(models.Model):
title = models.CharField(max_length=255, verbose_name='Программист')
content = models.TextField(blank=True, verbose_name='Компетенция')
- photo = models.ImageField(upload_to="photos/%Y/%m/%d/", verbose_name='Фото')
+ photo = models.ImageField(upload_to="photos/%Y/%m/%d/", blank=True, null=True, verbose_name='Фото')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@@ -65,12 +69,24 @@ class Solution(models.Model):
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован')
-
+ slug = models.SlugField(
+ max_length=255,
+ unique=False, # временно не уникальное
+ db_index=True,
+ verbose_name='URL-идентификатор',
+ blank=True,
+ null=True, # разрешаем NULL
+ )
def __str__(self):
return self.title
-
+
+ def save(self, *args, **kwargs):
+ if not self.slug:
+ self.slug = slugify(self.title)
+ super().save(*args, **kwargs)
+
def get_absolute_url(self):
- return reverse('solution_detail', kwargs={'pk': self.pk})
+ return reverse('solution_detail', kwargs={'slug': self.slug})
class Meta:
verbose_name = 'Проекты'
@@ -78,18 +94,17 @@ class Solution(models.Model):
ordering = ['time_create', 'title']
def get_seo_title(self):
- """Генерирует SEO-заголовок для проекта"""
return f"Проект: {self.title} | Автоматизация 1С"
def get_seo_description(self):
- """Генерирует SEO-описание для проекта"""
- if self.description:
- clean_desc = self.description[:160].replace('\n', ' ').strip()
- return f"Проект автоматизации: {self.title}. {clean_desc}..."
+ # Используем closing если есть, иначе description
+ source = self.closing or self.description
+ if source:
+ clean = source[:160].replace('\n', ' ').strip()
+ return f"Проект автоматизации: {self.title}. {clean}..."
return f"Реализация проекта {self.title} - программист 1С Николай Сердюк"
def get_meta_keywords(self):
- """Автоматические ключевые слова для проекта"""
base_keywords = ["проект 1С", "автоматизация 1С", "внедрение 1С"]
title_words = self.title.lower().split()
return base_keywords + title_words
@@ -98,7 +113,7 @@ class Solution(models.Model):
class Home(models.Model):
title = models.CharField(max_length=255, verbose_name='Наименование')
content = models.TextField(blank=True, verbose_name='Статья')
- home_image = models.ImageField(upload_to="home_image/%Y/%m/%d/", verbose_name='Фото')
+ home_image = models.ImageField(upload_to="home_image/%Y/%m/%d/", blank=True, null=True, verbose_name='Фото')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@@ -118,8 +133,8 @@ class Home(models.Model):
class CallbackRequest(models.Model):
name = models.CharField(max_length=100, verbose_name='Имя')
phone = models.CharField(max_length=20, verbose_name='Телефон')
- email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта') # Сделать необязательным
- question = models.TextField(blank=True, verbose_name='Ваш вопрос') # Сделать необязательным
+ email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта')
+ question = models.TextField(blank=True, verbose_name='Ваш вопрос')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
is_processed = models.BooleanField(default=False, verbose_name='Обработано')
is_read = models.BooleanField(default=False, verbose_name='Прочитано')
@@ -134,16 +149,18 @@ class CallbackRequest(models.Model):
ordering = ['-time_create']
-# Сигнал для отправки уведомления при создании заявки
@receiver(post_save, sender=CallbackRequest)
def send_callback_email_notification(sender, instance, created, **kwargs):
if created and not instance.notification_sent:
- # Отправляем email уведомление
- success = send_callback_notification(instance)
- if success:
- instance.notification_sent = True
- # Сохраняем без повторного вызова сигнала
- sender.objects.filter(pk=instance.pk).update(notification_sent=True)
+ try:
+ success = send_callback_notification(instance)
+ if success:
+ sender.objects.filter(pk=instance.pk).update(notification_sent=True)
+ except Exception:
+ # Логируем ошибку, но не прерываем сохранение объекта
+ logger.exception(
+ 'Не удалось отправить email-уведомление для заявки pk=%s', instance.pk
+ )
class PageView(models.Model):
@@ -190,8 +207,7 @@ class Profile(models.Model):
@receiver(post_save, sender=User)
def create_or_save_user_profile(sender, instance, created, **kwargs):
- # Получаем или создаём профиль, затем сохраняем
- if created:
+ if created:
Profile.objects.get_or_create(user=instance)
@@ -200,5 +216,4 @@ def create_or_save_user_profile(sender, instance, created, **kwargs):
@receiver([post_save, post_delete], sender=Competence)
@receiver([post_save, post_delete], sender=Recall)
def clear_sitemap_cache(sender, **kwargs):
- """Очищаем кэш sitemap при изменении контента"""
- cache.delete('sitemap_cache')
+ cache.delete('sitemap_cache')
\ No newline at end of file
diff --git a/programmer/services.py b/programmer/services.py
index c211f38..fcad442 100644
--- a/programmer/services.py
+++ b/programmer/services.py
@@ -1,10 +1,11 @@
+import logging
+
from typing import Optional
from django.http import HttpRequest
from .models import PageView, Visitor
from django.utils import timezone
-from typing import Type
-from django.db.models import Model, QuerySet
+logger = logging.getLogger(__name__)
def get_client_ip(request: HttpRequest) -> str:
@@ -22,13 +23,11 @@ def should_track_request(request: HttpRequest) -> bool:
client_ip = get_client_ip(request)
path = request.path
- # Игнорируемые пути
ignored_paths = [
'/static/', '/admin/', '/index.php', '/status.php',
'/cron', '/remote.php', '/ocs', '/apps/', '/custom_apps/',
]
- # Игнорируемые IP (Docker сети)
docker_ips = [
'192.168.64.1', '192.168.65.1',
'172.17.0.1', '172.18.0.1', '172.19.0.1',
@@ -48,7 +47,6 @@ def track_page_view(request: HttpRequest) -> None:
return
try:
- # Сохраняем просмотр страницы
PageView.objects.create(
url=request.path,
ip_address=get_client_ip(request),
@@ -56,13 +54,12 @@ def track_page_view(request: HttpRequest) -> None:
referer=request.META.get('HTTP_REFERER', '')[:500],
)
- # Обновляем статистику посетителя
ip = get_client_ip(request)
visitor, created = Visitor.objects.get_or_create(
ip_address=ip,
defaults={
'first_visit': timezone.now(),
- 'last_visit': timezone.now()
+ 'last_visit': timezone.now(),
}
)
@@ -71,26 +68,14 @@ def track_page_view(request: HttpRequest) -> None:
visitor.visit_count += 1
visitor.save()
- except Exception as e:
- # В продакшене лучше использовать логирование
- print(f"Error tracking page view: {e}")
+ except Exception:
+ logger.exception('Ошибка при трекинге просмотра страницы: %s', request.path)
def get_published_queryset(model_class, order_by: str = '-time_create'):
"""
Возвращает QuerySet опубликованных записей для модели.
-
- Args:
- model_class: Класс модели Django
- order_by: Поле для сортировки
-
- Returns:
- QuerySet или пустой список, если поле is_published отсутствует
"""
if hasattr(model_class, 'is_published'):
return model_class.objects.filter(is_published=True).order_by(order_by)
- return model_class.objects.none()
-
-
-
-
+ return model_class.objects.none()
\ No newline at end of file
diff --git a/programmer/static/programmer/css/solution-cards.css b/programmer/static/programmer/css/solution-cards.css
new file mode 100644
index 0000000..dad0931
--- /dev/null
+++ b/programmer/static/programmer/css/solution-cards.css
@@ -0,0 +1,194 @@
+/* ===== PROJECTS GRID ===== */
+.projects-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+/* ===== PROJECT CARD ===== */
+.project-card {
+ background: var(--bg-card);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-light);
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ overflow: hidden;
+ transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
+}
+
+.project-card:hover {
+ transform: translateY(-4px);
+ box-shadow: var(--shadow-lg);
+ border-color: var(--primary-light);
+}
+
+/* Оранжевая полоска сверху — единственный декоративный слой */
+.project-card__accent {
+ height: 3px;
+ background: var(--gradient-primary);
+ flex-shrink: 0;
+}
+
+.project-card__body {
+ padding: 1.5rem 1.5rem 1rem;
+ flex: 1;
+}
+
+.project-card__title {
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 0.75rem;
+ line-height: 1.4;
+}
+
+.project-card__desc {
+ font-size: 0.95rem;
+ color: var(--text-secondary);
+ line-height: 1.65;
+ margin: 0;
+}
+
+.project-card__footer {
+ padding: 1rem 1.5rem 1.25rem;
+ border-top: 1px solid var(--border-light);
+ display: flex;
+ align-items: center;
+}
+
+.project-card__link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--primary);
+ font-weight: 600;
+ font-size: 0.9rem;
+ text-decoration: none;
+ transition: gap 0.2s ease, color 0.2s ease;
+}
+
+.project-card__link:hover {
+ color: var(--primary-dark);
+ gap: 0.65rem;
+}
+
+/* ===== MOBILE ===== */
+@media (max-width: 768px) {
+ .projects-grid {
+ grid-template-columns: 1fr;
+ gap: 1rem;
+ }
+
+ /* На мобиле убираем hover-подъём — он не работает на touch */
+ .project-card:hover {
+ transform: none;
+ box-shadow: none;
+ }
+
+ /* Вместо этого — активное состояние при нажатии */
+ .project-card:active {
+ border-color: var(--primary);
+ }
+
+ .project-card__body {
+ padding: 1.25rem 1.25rem 0.75rem;
+ }
+
+ .project-card__footer {
+ padding: 0.75rem 1.25rem 1rem;
+ }
+
+ .project-card__title {
+ font-size: 1rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .projects-grid {
+ gap: 0.75rem;
+ }
+}
+
+/* ===== СТИЛИ ДЛЯ СТРАНИЦЫ ДЕТАЛЬНОГО РЕШЕНИЯ ===== */
+
+.solution-section {
+ margin-bottom: 2.5rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid var(--border-light);
+}
+
+.solution-section:last-of-type {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+.section-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ margin-bottom: 1.5rem;
+ color: var(--primary);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border-left: 5px solid var(--primary);
+ padding-left: 1.25rem;
+}
+
+.section-content {
+ font-size: 1.05rem;
+ line-height: 1.7;
+ color: var(--text-secondary);
+}
+
+/* Вложенные заголовки */
+.section-content h2,
+.section-content h3 {
+ margin-top: 1.5em;
+ margin-bottom: 0.75em;
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.section-content h2 {
+ font-size: 1.4rem;
+ border-bottom: 1px dashed var(--border-light);
+ padding-bottom: 0.5rem;
+}
+
+.section-content h3 {
+ font-size: 1.2rem;
+}
+
+.section-content ul,
+.section-content ol {
+ margin: 1rem 0 1rem 1.5rem;
+}
+
+.section-content li {
+ margin-bottom: 0.5rem;
+}
+
+/* Адаптация для мобильных */
+@media (max-width: 768px) {
+ .solution-section {
+ margin-bottom: 2rem;
+ padding-bottom: 1rem;
+ }
+ .section-title {
+ font-size: 1.5rem;
+ padding-left: 1rem;
+ border-left-width: 4px;
+ }
+ .section-content {
+ font-size: 1rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .section-title {
+ font-size: 1.35rem;
+ }
+}
\ No newline at end of file
diff --git a/programmer/templates/programmer/includes/project_cards.html b/programmer/templates/programmer/includes/project_cards.html
index ac9b838..e0d7f7f 100644
--- a/programmer/templates/programmer/includes/project_cards.html
+++ b/programmer/templates/programmer/includes/project_cards.html
@@ -1,5 +1,25 @@
-{% autoescape off %}
-{% for post in posts %}
- {% include 'programmer/includes/project_card.html' %}
-{% endfor %}
-{% endautoescape %}
\ No newline at end of file
+{% for solution in posts %}
+
+
+
+
+
+
+ {% if solution.description %}
+
{{ solution.description|striptags|truncatewords:25 }}
+ {% endif %}
+
+
+
+
+{% endfor %}
\ No newline at end of file
diff --git a/programmer/templates/programmer/register.html b/programmer/templates/programmer/register.html
index a0db152..e5efb2e 100644
--- a/programmer/templates/programmer/register.html
+++ b/programmer/templates/programmer/register.html
@@ -30,6 +30,8 @@
{% bootstrap_field form.specialization %}
+
+ {{ form.captcha }}
diff --git a/programmer/templates/programmer/solution.html b/programmer/templates/programmer/solution.html
index 957a2e4..f5d67c6 100644
--- a/programmer/templates/programmer/solution.html
+++ b/programmer/templates/programmer/solution.html
@@ -1,29 +1,21 @@
{% extends 'programmer/base.html' %}
-{% load django_bootstrap5 %}
{% load static %}
{% load seo_tags %}
-
{% block extra_css %}
-
+
{% endblock %}
{% block content %}
-
- {{ solution.title }}
- {{ solution.description }}
- {{ solution.implementation|safe }}
-
-
-
+
{% include 'programmer/includes/project_cards.html' %}
-
+
{% if not posts %}
@@ -32,15 +24,15 @@
{% endif %}
-
{% if page_obj.has_next %}
-
-
Загрузка...
-
Загрузка проектов...
+
+
+ Загрузка...
+
Загрузка проектов...
+
{% endif %}
-
-
-
+
+
{% endblock %}
\ No newline at end of file
diff --git a/programmer/templates/programmer/solution_detail.html b/programmer/templates/programmer/solution_detail.html
new file mode 100644
index 0000000..bf5677a
--- /dev/null
+++ b/programmer/templates/programmer/solution_detail.html
@@ -0,0 +1,109 @@
+{% extends 'programmer/base.html' %}
+{% load static %}
+{% load seo_tags %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+ {# Хлебные крошки #}
+ {% if breadcrumbs %}
+
+ {% endif %}
+
+ {# Заголовок страницы #}
+
+
+ {# Основное содержимое #}
+
+ {# Секция: Описание задачи #}
+
+ 📋 Описание задачи
+
+ {{ solution.description|safe }}
+
+
+
+ {# Секция: Описание решения #}
+
+ 🔧 Описание решения
+
+ {{ solution.implementation|safe }}
+
+
+
+ {# Секция: Результат #}
+
+ ✅ Результат
+
+ {{ solution.closing|safe }}
+
+
+
+ {# Кнопка возврата #}
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/programmer/urls.py b/programmer/urls.py
index f5d2461..d89190d 100644
--- a/programmer/urls.py
+++ b/programmer/urls.py
@@ -14,7 +14,7 @@ urlpatterns = [
path('', views.HomePageView.as_view(), name='home'),
path('about/', views.AboutPageView.as_view(), name='about'),
path('solutions/', views.SolutionListView.as_view(), name='solution'),
- path('solutions/
/', SolutionDetailView.as_view(), name='solution_detail'),
+ path('solutions//', SolutionDetailView.as_view(), name='solution_detail'),
# path('competence/', ability, name='ability'),
# path('competence//', CompetenceDetailView.as_view(), name='competence_detail'),
path('recall/', views.RecallListView.as_view(), name='recall'),
diff --git a/programmer/views.py b/programmer/views.py
index 8c0bac4..6cb72c8 100644
--- a/programmer/views.py
+++ b/programmer/views.py
@@ -228,6 +228,10 @@ class RegisterView(PageViewTrackingMixin, MenuContextMixin, SuccessMessageMixin,
})
return context
+
+ def __init__(self, *args, **kwargs):
+ self.request = kwargs.pop('request', None)
+ super().__init__(*args, **kwargs)
class ProfileEditView(LoginRequiredMixin, PageViewTrackingMixin, MenuContextMixin, BreadcrumbMixin, UpdateView):