Compare commits

...

4 Commits

12 changed files with 500 additions and 92 deletions

View File

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

View File

@ -3,7 +3,8 @@ 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.contrib import messages from django.db import models
from django_ckeditor_5.widgets import CKEditor5Widget
from .models import Home, Solution, Competence, Recall, CallbackRequest, PageView 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 ===== # ===== BASE ADMIN CLASSES =====
class BaseContentAdmin(admin.ModelAdmin): class BaseContentAdmin(admin.ModelAdmin):
"""Общий базовый класс для контентных моделей.""" """
Базовый класс для контентных моделей.
CKEditor, actions публикации, date_hierarchy.
"""
list_display_links = ('id', 'title') list_display_links = ('id', 'title')
list_editable = ('is_published',) list_editable = ('is_published',)
list_filter = ('time_create', 'is_published') list_filter = ('time_create', 'is_published')
search_fields = ('title',) 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 ===== # ===== MODEL ADMINS =====
@admin.register(Home) @admin.register(Home)
class HomeAdmin(BaseContentAdmin): class HomeAdmin(BaseContentAdmin):
# Поля: title, content, home_image, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'is_published') list_display = ('id', 'title', 'time_create', 'is_published')
search_fields = ('title', 'content') 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) @admin.register(Solution)
class SolutionAdmin(BaseContentAdmin): class SolutionAdmin(BaseContentAdmin):
# Поля: title, description, implementation, closing, time_create, time_update, is_published
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')
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) @admin.register(Competence)
class CompetenceAdmin(BaseContentAdmin): class CompetenceAdmin(BaseContentAdmin):
# Поля: title, content, photo, time_create, time_update, is_published
list_display = ('id', 'title', 'time_create', 'is_published') list_display = ('id', 'title', 'time_create', 'is_published')
search_fields = ('title', 'content') search_fields = ('title', 'content')
fieldsets = (
(None, {
'fields': ('title', 'is_published'),
}),
('Содержание', {
'fields': ('content', 'photo'),
'description': 'Описание компетенции и фото.',
}),
('Служебное', {
'fields': ('time_create', 'time_update'),
'classes': ('collapse',),
}),
)
@admin.register(Recall) @admin.register(Recall)
class RecallAdmin(BaseContentAdmin): 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') 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) @admin.register(CallbackRequest)
class CallbackAdmin(admin.ModelAdmin): class CallbackAdmin(admin.ModelAdmin):
@ -54,16 +151,12 @@ class CallbackAdmin(admin.ModelAdmin):
readonly_fields = ('time_create',) readonly_fields = ('time_create',)
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): def new_badge(self, obj):
if not obj.is_read: if not obj.is_read:
return format_html('<span style="color:red;font-weight:bold;">🆕 НОВАЯ</span>') return format_html('<span style="color:red;font-weight:bold;">🆕 НОВАЯ</span>')
return '' return ''
new_badge.short_description = 'Статус' new_badge.short_description = 'Статус'
# ── bulk actions ──────────────────────────────────────────────────────────
def mark_as_read(self, request, queryset): def mark_as_read(self, request, queryset):
updated = queryset.update(is_read=True) updated = queryset.update(is_read=True)
self.message_user(request, f'{updated} заявок отмечены как прочитанные') self.message_user(request, f'{updated} заявок отмечены как прочитанные')
@ -85,8 +178,6 @@ class CallbackAdmin(admin.ModelAdmin):
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 get_urls(self): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
custom_urls = [ custom_urls = [
@ -117,11 +208,8 @@ 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.
# ===== PAGE VIEWS =====
@admin.register(PageView) @admin.register(PageView)
class PageViewAdmin(admin.ModelAdmin): class PageViewAdmin(admin.ModelAdmin):
@ -134,7 +222,6 @@ class PageViewAdmin(admin.ModelAdmin):
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')
# PageViews should never be created or edited manually
def has_add_permission(self, request): def has_add_permission(self, request):
return False return False

View File

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

View File

@ -1,3 +1,5 @@
import logging
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -7,11 +9,13 @@ from django.dispatch import receiver
from django.core.cache import cache from django.core.cache import cache
from .utils.email_notifications import send_callback_notification from .utils.email_notifications import send_callback_notification
logger = logging.getLogger(__name__)
class Recall(models.Model): class Recall(models.Model):
title = models.CharField(max_length=255, verbose_name='Организация') title = models.CharField(max_length=255, verbose_name='Организация')
content = models.TextField(blank=True, 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_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения') time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован') is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@ -40,7 +44,7 @@ class Recall(models.Model):
class Competence(models.Model): class Competence(models.Model):
title = models.CharField(max_length=255, verbose_name='Программист') title = models.CharField(max_length=255, verbose_name='Программист')
content = models.TextField(blank=True, 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_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения') time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=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_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения') time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=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): def __str__(self):
return self.title 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): def get_absolute_url(self):
return reverse('solution_detail', kwargs={'pk': self.pk}) return reverse('solution_detail', kwargs={'slug': self.slug})
class Meta: class Meta:
verbose_name = 'Проекты' verbose_name = 'Проекты'
@ -78,18 +94,17 @@ class Solution(models.Model):
ordering = ['time_create', 'title'] ordering = ['time_create', 'title']
def get_seo_title(self): def get_seo_title(self):
"""Генерирует SEO-заголовок для проекта"""
return f"Проект: {self.title} | Автоматизация 1С" return f"Проект: {self.title} | Автоматизация 1С"
def get_seo_description(self): def get_seo_description(self):
"""Генерирует SEO-описание для проекта""" # Используем closing если есть, иначе description
if self.description: source = self.closing or self.description
clean_desc = self.description[:160].replace('\n', ' ').strip() if source:
return f"Проект автоматизации: {self.title}. {clean_desc}..." clean = source[:160].replace('\n', ' ').strip()
return f"Проект автоматизации: {self.title}. {clean}..."
return f"Реализация проекта {self.title} - программист 1С Николай Сердюк" return f"Реализация проекта {self.title} - программист 1С Николай Сердюк"
def get_meta_keywords(self): def get_meta_keywords(self):
"""Автоматические ключевые слова для проекта"""
base_keywords = ["проект 1С", "автоматизация 1С", "внедрение 1С"] base_keywords = ["проект 1С", "автоматизация 1С", "внедрение 1С"]
title_words = self.title.lower().split() title_words = self.title.lower().split()
return base_keywords + title_words return base_keywords + title_words
@ -98,7 +113,7 @@ class Solution(models.Model):
class Home(models.Model): class Home(models.Model):
title = models.CharField(max_length=255, verbose_name='Наименование') title = models.CharField(max_length=255, verbose_name='Наименование')
content = models.TextField(blank=True, 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_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения') time_update = models.DateTimeField(auto_now=True, verbose_name='Дата изменения')
is_published = models.BooleanField(default=True, verbose_name='Опубликован') is_published = models.BooleanField(default=True, verbose_name='Опубликован')
@ -118,8 +133,8 @@ class Home(models.Model):
class CallbackRequest(models.Model): class CallbackRequest(models.Model):
name = models.CharField(max_length=100, verbose_name='Имя') name = models.CharField(max_length=100, verbose_name='Имя')
phone = models.CharField(max_length=20, verbose_name='Телефон') phone = models.CharField(max_length=20, verbose_name='Телефон')
email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта') # Сделать необязательным email = models.EmailField(blank=True, null=True, verbose_name='Электронная почта')
question = models.TextField(blank=True, verbose_name='Ваш вопрос') # Сделать необязательным question = models.TextField(blank=True, verbose_name='Ваш вопрос')
time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') time_create = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')
is_processed = models.BooleanField(default=False, verbose_name='Обработано') is_processed = models.BooleanField(default=False, verbose_name='Обработано')
is_read = 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'] ordering = ['-time_create']
# Сигнал для отправки уведомления при создании заявки
@receiver(post_save, sender=CallbackRequest) @receiver(post_save, sender=CallbackRequest)
def send_callback_email_notification(sender, instance, created, **kwargs): def send_callback_email_notification(sender, instance, created, **kwargs):
if created and not instance.notification_sent: if created and not instance.notification_sent:
# Отправляем email уведомление try:
success = send_callback_notification(instance) success = send_callback_notification(instance)
if success: if success:
instance.notification_sent = True
# Сохраняем без повторного вызова сигнала
sender.objects.filter(pk=instance.pk).update(notification_sent=True) sender.objects.filter(pk=instance.pk).update(notification_sent=True)
except Exception:
# Логируем ошибку, но не прерываем сохранение объекта
logger.exception(
'Не удалось отправить email-уведомление для заявки pk=%s', instance.pk
)
class PageView(models.Model): class PageView(models.Model):
@ -190,7 +207,6 @@ class Profile(models.Model):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_or_save_user_profile(sender, instance, created, **kwargs): def create_or_save_user_profile(sender, instance, created, **kwargs):
# Получаем или создаём профиль, затем сохраняем
if created: if created:
Profile.objects.get_or_create(user=instance) 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=Competence)
@receiver([post_save, post_delete], sender=Recall) @receiver([post_save, post_delete], sender=Recall)
def clear_sitemap_cache(sender, **kwargs): def clear_sitemap_cache(sender, **kwargs):
"""Очищаем кэш sitemap при изменении контента"""
cache.delete('sitemap_cache') cache.delete('sitemap_cache')

View File

@ -1,10 +1,11 @@
import logging
from typing import Optional from typing import Optional
from django.http import HttpRequest from django.http import HttpRequest
from .models import PageView, Visitor from .models import PageView, Visitor
from django.utils import timezone from django.utils import timezone
from typing import Type logger = logging.getLogger(__name__)
from django.db.models import Model, QuerySet
def get_client_ip(request: HttpRequest) -> str: def get_client_ip(request: HttpRequest) -> str:
@ -22,13 +23,11 @@ def should_track_request(request: HttpRequest) -> bool:
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
path = request.path path = request.path
# Игнорируемые пути
ignored_paths = [ ignored_paths = [
'/static/', '/admin/', '/index.php', '/status.php', '/static/', '/admin/', '/index.php', '/status.php',
'/cron', '/remote.php', '/ocs', '/apps/', '/custom_apps/', '/cron', '/remote.php', '/ocs', '/apps/', '/custom_apps/',
] ]
# Игнорируемые IP (Docker сети)
docker_ips = [ docker_ips = [
'192.168.64.1', '192.168.65.1', '192.168.64.1', '192.168.65.1',
'172.17.0.1', '172.18.0.1', '172.19.0.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 return
try: try:
# Сохраняем просмотр страницы
PageView.objects.create( PageView.objects.create(
url=request.path, url=request.path,
ip_address=get_client_ip(request), ip_address=get_client_ip(request),
@ -56,13 +54,12 @@ def track_page_view(request: HttpRequest) -> None:
referer=request.META.get('HTTP_REFERER', '')[:500], referer=request.META.get('HTTP_REFERER', '')[:500],
) )
# Обновляем статистику посетителя
ip = get_client_ip(request) ip = get_client_ip(request)
visitor, created = Visitor.objects.get_or_create( visitor, created = Visitor.objects.get_or_create(
ip_address=ip, ip_address=ip,
defaults={ defaults={
'first_visit': timezone.now(), '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.visit_count += 1
visitor.save() visitor.save()
except Exception as e: except Exception:
# В продакшене лучше использовать логирование logger.exception('Ошибка при трекинге просмотра страницы: %s', request.path)
print(f"Error tracking page view: {e}")
def get_published_queryset(model_class, order_by: str = '-time_create'): def get_published_queryset(model_class, order_by: str = '-time_create'):
""" """
Возвращает QuerySet опубликованных записей для модели. Возвращает QuerySet опубликованных записей для модели.
Args:
model_class: Класс модели Django
order_by: Поле для сортировки
Returns:
QuerySet или пустой список, если поле is_published отсутствует
""" """
if hasattr(model_class, 'is_published'): if hasattr(model_class, 'is_published'):
return model_class.objects.filter(is_published=True).order_by(order_by) return model_class.objects.filter(is_published=True).order_by(order_by)
return model_class.objects.none() return model_class.objects.none()

View File

@ -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;
}
}

View File

@ -1,5 +1,25 @@
{% autoescape off %} {% for solution in posts %}
{% for post in posts %} <article class="project-card">
{% include 'programmer/includes/project_card.html' %} <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 %} {% endfor %}
{% endautoescape %}

View File

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

View File

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

View File

@ -0,0 +1,109 @@
{% 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('', views.HomePageView.as_view(), name='home'),
path('about/', views.AboutPageView.as_view(), name='about'), path('about/', views.AboutPageView.as_view(), name='about'),
path('solutions/', views.SolutionListView.as_view(), name='solution'), path('solutions/', views.SolutionListView.as_view(), name='solution'),
path('solutions/<int:pk>/', SolutionDetailView.as_view(), name='solution_detail'), path('solutions/<slug:slug>/', SolutionDetailView.as_view(), name='solution_detail'),
# path('competence/', ability, name='ability'), # path('competence/', ability, name='ability'),
# path('competence/<int:pk>/', CompetenceDetailView.as_view(), name='competence_detail'), # path('competence/<int:pk>/', CompetenceDetailView.as_view(), name='competence_detail'),
path('recall/', views.RecallListView.as_view(), name='recall'), path('recall/', views.RecallListView.as_view(), name='recall'),

View File

@ -229,6 +229,10 @@ class RegisterView(PageViewTrackingMixin, MenuContextMixin, SuccessMessageMixin,
return context return context
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None)
super().__init__(*args, **kwargs)
class ProfileEditView(LoginRequiredMixin, PageViewTrackingMixin, MenuContextMixin, BreadcrumbMixin, UpdateView): class ProfileEditView(LoginRequiredMixin, PageViewTrackingMixin, MenuContextMixin, BreadcrumbMixin, UpdateView):
model = Profile model = Profile