Compare commits

..

No commits in common. "223fea53698a71e2762f476c395a28ecb5e2c18a" and "f22849878b38795e72c4bec73d151401e587a31a" have entirely different histories.

12 changed files with 92 additions and 500 deletions

View File

@ -60,12 +60,15 @@
{% endif %}
</div>
<!-- Скрипт для модального окна -->
<script src="{% static 'programmer/js/recall.js' %}"></script>
<!-- Модальное окно для увеличения изображений -->
<div id="imageModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Просмотр изображения</h3>
<button class="modal-close" onclick="ImageModal.close()">&times;</button>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<img class="modal-image" id="modalImage" alt="">
@ -73,9 +76,6 @@
</div>
</div>
<!-- Скрипт для модального окна -->
<script src="{% static 'programmer/js/recall.js' %}"></script>
{% endblock %}
{% block extra_js %}
@ -90,7 +90,7 @@
img.style.cursor = 'pointer';
img.addEventListener('click', function() {
// Вызываем глобальную функцию openModal из recall.js
ImageModal.open(this.src, this.alt || 'Изображение из статьи');
openModal(this.src, this.alt || 'Изображение из статьи');
});
});
});

View File

@ -3,8 +3,7 @@ from django.utils import timezone
from django.utils.html import format_html
from django.urls import path
from django.shortcuts import render
from django.db import models
from django_ckeditor_5.widgets import CKEditor5Widget
from django.contrib import messages
from .models import Home, Solution, Competence, Recall, CallbackRequest, PageView
@ -12,134 +11,38 @@ 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):
# Поля: title, content, scan, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'scan_preview', 'is_published')
list_display = ('id', 'title', 'time_create', 'scan', '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(
'<img src="{}" style="max-height:200px; max-width:400px; '
'border-radius:4px; border:1px solid #ddd;" />',
obj.scan.url
)
return ''
scan_preview.short_description = 'Превью скана'
# ===== CALLBACK =====
@admin.register(CallbackRequest)
class CallbackAdmin(admin.ModelAdmin):
@ -151,12 +54,16 @@ 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('<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} заявок отмечены как прочитанные')
@ -178,6 +85,8 @@ 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 = [
@ -208,8 +117,11 @@ 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):
@ -222,8 +134,9 @@ 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

View File

@ -78,7 +78,6 @@ class ProfileForm(forms.ModelForm):
class RegistrationForm(UserCreationForm):
captcha = TurnstileField(label='', theme='light', size='normal')
# Поля пользователя
first_name = forms.CharField(
max_length=30,

View File

@ -1,5 +1,3 @@
import logging
from django.contrib.auth.models import User
from django.db import models
from django.urls import reverse
@ -9,13 +7,11 @@ 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/", blank=True, null=True, verbose_name='Фото')
scan = models.ImageField(upload_to="scan/%Y/%m/%d/", 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='Опубликован')
@ -44,7 +40,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/", blank=True, null=True, verbose_name='Фото')
photo = models.ImageField(upload_to="photos/%Y/%m/%d/", 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='Опубликован')
@ -69,24 +65,12 @@ 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={'slug': self.slug})
return reverse('solution_detail', kwargs={'pk': self.pk})
class Meta:
verbose_name = 'Проекты'
@ -94,17 +78,18 @@ class Solution(models.Model):
ordering = ['time_create', 'title']
def get_seo_title(self):
"""Генерирует SEO-заголовок для проекта"""
return f"Проект: {self.title} | Автоматизация 1С"
def get_seo_description(self):
# Используем closing если есть, иначе description
source = self.closing or self.description
if source:
clean = source[:160].replace('\n', ' ').strip()
return f"Проект автоматизации: {self.title}. {clean}..."
"""Генерирует SEO-описание для проекта"""
if self.description:
clean_desc = self.description[:160].replace('\n', ' ').strip()
return f"Проект автоматизации: {self.title}. {clean_desc}..."
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
@ -113,7 +98,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/", blank=True, null=True, verbose_name='Фото')
home_image = models.ImageField(upload_to="home_image/%Y/%m/%d/", 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='Опубликован')
@ -133,8 +118,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='Прочитано')
@ -149,18 +134,16 @@ 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:
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
)
# Отправляем email уведомление
success = send_callback_notification(instance)
if success:
instance.notification_sent = True
# Сохраняем без повторного вызова сигнала
sender.objects.filter(pk=instance.pk).update(notification_sent=True)
class PageView(models.Model):
@ -207,7 +190,8 @@ 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)
@ -216,4 +200,5 @@ 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):
cache.delete('sitemap_cache')
"""Очищаем кэш sitemap при изменении контента"""
cache.delete('sitemap_cache')

View File

@ -1,11 +1,10 @@
import logging
from typing import Optional
from django.http import HttpRequest
from .models import PageView, Visitor
from django.utils import timezone
logger = logging.getLogger(__name__)
from typing import Type
from django.db.models import Model, QuerySet
def get_client_ip(request: HttpRequest) -> str:
@ -23,11 +22,13 @@ 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',
@ -47,6 +48,7 @@ def track_page_view(request: HttpRequest) -> None:
return
try:
# Сохраняем просмотр страницы
PageView.objects.create(
url=request.path,
ip_address=get_client_ip(request),
@ -54,12 +56,13 @@ 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()
}
)
@ -68,14 +71,26 @@ def track_page_view(request: HttpRequest) -> None:
visitor.visit_count += 1
visitor.save()
except Exception:
logger.exception('Ошибка при трекинге просмотра страницы: %s', request.path)
except Exception as e:
# В продакшене лучше использовать логирование
print(f"Error tracking page view: {e}")
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()

View File

@ -1,194 +0,0 @@
/* ===== 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;
}
}

View File

@ -1,25 +1,5 @@
{% for solution in posts %}
<article class="project-card">
<div class="project-card__accent"></div>
<div class="project-card__body">
<h2 class="card-title">
<a href="{{ solution.get_absolute_url }}">{{ solution.title }}</a>
</h2>
{% if solution.description %}
<p class="project-card__desc">{{ solution.description|striptags|truncatewords:25 }}</p>
{% endif %}
</div>
<div class="project-card__footer">
<a href="{{ solution.get_absolute_url }}" class="project-card__link">
Подробнее
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
</div>
</article>
{% endfor %}
{% autoescape off %}
{% for post in posts %}
{% include 'programmer/includes/project_card.html' %}
{% endfor %}
{% endautoescape %}

View File

@ -30,8 +30,6 @@
{% bootstrap_field form.specialization %}
<button type="submit" class="btn btn-primary" style="width: 100%;">Зарегистрироваться</button>
<!-- {{ form.as_p }} -->
{{ form.captcha }}
</form>
<hr>
<p class="text-center">

View File

@ -1,21 +1,29 @@
{% extends 'programmer/base.html' %}
{% load django_bootstrap5 %}
{% load static %}
{% load seo_tags %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'programmer/css/solution-cards.css' %}">
<link rel="stylesheet" href="{% static 'programmer/css/solution-accordion.css' %}">
{% endblock %}
{% block content %}
<article>
<h1>{{ solution.title }}</h1>
<p>{{ solution.description }}</p>
<section>{{ solution.implementation|safe }}</section>
<footer>{{ solution.closing|safe }}</footer>
</article>
<div class="page-header">
<h1 class="page-title">Проекты автоматизации 1С</h1>
<p class="page-subtitle">Реализованные решения и кейсы по автоматизации бизнес-процессов</p>
</div>
<div class="projects-grid" id="projects-container">
<ul class="improved-list" id="projects-container">
{% include 'programmer/includes/project_cards.html' %}
</div>
</ul>
{% if not posts %}
<div class="content-card text-center">
@ -24,15 +32,15 @@
</div>
{% endif %}
<!-- Индикатор загрузки -->
{% if page_obj.has_next %}
<div id="loading-spinner" style="display: none; text-align: center; margin: 2rem 0;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
<div id="loading-spinner" style="display: none; text-align: center; margin: 20px 0;">
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div>
<p>Загрузка проектов...</p>
</div>
<p style="margin-top: 0.5rem; color: var(--text-secondary);">Загрузка проектов...</p>
</div>
{% endif %}
<!-- Данные пагинации -->
<script>
window.currentPage = {{ page_obj.number }};
window.totalPages = {{ paginator.num_pages }};
@ -41,6 +49,7 @@
{% endblock %}
{% block extra_js %}
<script src="{% static 'programmer/js/infinite_scroll.js' %}"></script>
<script src="{% static 'programmer/js/floating-button.js' %}"></script>
<script src="{% static 'programmer/js/solution-accordion.js' %}"></script>
<script src="{% static 'programmer/js/infinite_scroll.js' %}"></script>
<script src="{% static 'programmer/js/floating-button.js' %}"></script>
{% endblock %}

View File

@ -1,109 +0,0 @@
{% extends 'programmer/base.html' %}
{% load static %}
{% load seo_tags %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'programmer/css/solution-cards.css' %}">
{% endblock %}
{% block content %}
<div class="container">
{# Хлебные крошки #}
{% if breadcrumbs %}
<nav class="breadcrumbs" aria-label="Навигация">
{% for crumb in breadcrumbs %}
{% if crumb.url_name %}
<a href="{% url crumb.url_name %}" class="breadcrumb-link">{{ crumb.title }}</a>
<span class="breadcrumb-separator">/</span>
{% else %}
<span class="breadcrumb-current">{{ crumb.title }}</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}
{# Заголовок страницы #}
<div class="page-header">
<h1 class="page-title">{{ solution.title }}</h1>
<p class="page-subtitle">
Опубликовано: {{ solution.time_create|date:"d.m.Y" }}
</p>
</div>
{# Основное содержимое #}
<article class="content-card">
{# Секция: Описание задачи #}
<section class="solution-section">
<h2 class="section-title">📋 Описание задачи</h2>
<div class="section-content">
{{ solution.description|safe }}
</div>
</section>
{# Секция: Описание решения #}
<section class="solution-section">
<h2 class="section-title">🔧 Описание решения</h2>
<div class="section-content">
{{ solution.implementation|safe }}
</div>
</section>
{# Секция: Результат #}
<section class="solution-section">
<h2 class="section-title">✅ Результат</h2>
<div class="section-content">
{{ solution.closing|safe }}
</div>
</section>
{# Кнопка возврата #}
<div style="margin-top: 2rem; text-align: center;">
<a href="{% url 'solution' %}" class="btn btn-outline">
← Вернуться к списку проектов
</a>
</div>
</article>
</div>
{% endblock %}
{% block extra_js %}
<script>
function toggleAccordion(header) {
const content = header.nextElementSibling;
const icon = header.querySelector('.accordion-icon');
// Закрываем все другие открытые элементы в этом аккордеоне (опционально)
const accordion = header.closest('.solution-accordion');
if (accordion) {
accordion.querySelectorAll('.accordion-header').forEach(otherHeader => {
if (otherHeader !== header) {
const otherContent = otherHeader.nextElementSibling;
const otherIcon = otherHeader.querySelector('.accordion-icon');
otherContent.classList.remove('active');
otherHeader.classList.remove('active');
if (otherIcon) otherIcon.style.transform = 'rotate(0deg)';
}
});
}
// Переключаем текущий элемент
content.classList.toggle('active');
header.classList.toggle('active');
if (content.classList.contains('active')) {
icon.style.transform = 'rotate(180deg)';
} else {
icon.style.transform = 'rotate(0deg)';
}
}
// Инициализация: первый элемент открыт по умолчанию
document.addEventListener('DOMContentLoaded', function() {
const firstHeader = document.querySelector('.accordion-header');
if (firstHeader) {
toggleAccordion(firstHeader);
}
});
</script>
<script src="{% static 'programmer/js/floating-button.js' %}"></script>
{% endblock %}

View File

@ -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/<slug:slug>/', SolutionDetailView.as_view(), name='solution_detail'),
path('solutions/<int:pk>/', SolutionDetailView.as_view(), name='solution_detail'),
# path('competence/', ability, name='ability'),
# path('competence/<int:pk>/', CompetenceDetailView.as_view(), name='competence_detail'),
path('recall/', views.RecallListView.as_view(), name='recall'),

View File

@ -228,10 +228,6 @@ 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):