From df0e27696cb709f819a3da0333a01e7c60c0b7b3 Mon Sep 17 00:00:00 2001 From: NikDizell Date: Thu, 9 Apr 2026 16:48:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D0=B0=D1=82=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D1=80=D1=83=D0=B3=D0=B8=D0=B5=20=D0=BC=D0=B5=D0=BB=D0=BA=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- programmer/admin.py | 33 ++- programmer/models.py | 47 +++-- programmer/services.py | 29 +-- .../static/programmer/css/solution-cards.css | 194 ++++++++++++++++++ .../programmer/includes/project_cards.html | 30 ++- programmer/templates/programmer/solution.html | 50 +++-- .../templates/programmer/solution_detail.html | 109 ++++++++++ 7 files changed, 422 insertions(+), 70 deletions(-) create mode 100644 programmer/static/programmer/css/solution-cards.css create mode 100644 programmer/templates/programmer/solution_detail.html diff --git a/programmer/admin.py b/programmer/admin.py index 12e6313..53c0cb1 100644 --- a/programmer/admin.py +++ b/programmer/admin.py @@ -22,6 +22,7 @@ class BaseContentAdmin(admin.ModelAdmin): 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')}, @@ -54,6 +55,10 @@ class HomeAdmin(BaseContentAdmin): 'fields': ('content', 'home_image'), 'description': 'Основной текст главной страницы.', }), + ('Служебное', { + 'fields': ('time_create', 'time_update'), + 'classes': ('collapse',), + }), ) @@ -61,6 +66,7 @@ class HomeAdmin(BaseContentAdmin): 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') fieldsets = ( @@ -71,6 +77,10 @@ class SolutionAdmin(BaseContentAdmin): 'fields': ('description', 'implementation', 'closing'), 'description': 'Описание, реализация и заключение по проекту.', }), + ('Служебное', { + 'fields': ('time_create', 'time_update'), + 'classes': ('collapse',), + }), ) @@ -88,13 +98,17 @@ class CompetenceAdmin(BaseContentAdmin): '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', 'is_published') + list_display = ('id', 'title', 'time_create', 'scan_preview', 'is_published') search_fields = ('title', 'content') fieldsets = ( @@ -102,10 +116,25 @@ class RecallAdmin(BaseContentAdmin): 'fields': ('title', 'is_published'), }), ('Содержание', { - 'fields': ('content', 'scan'), + '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 ===== diff --git a/programmer/models.py b/programmer/models.py index cc22f36..14685de 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='Опубликован') @@ -78,18 +82,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 +101,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 +121,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 +137,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 +195,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 +204,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 %} +
+
+ +
+

+ {{ solution.title }} +

+ + {% if solution.description %} +

{{ solution.description|striptags|truncatewords:25 }}

+ {% endif %} +
+ + +
+{% endfor %} \ No newline at end of file diff --git a/programmer/templates/programmer/solution.html b/programmer/templates/programmer/solution.html index 957a2e4..06157f8 100644 --- a/programmer/templates/programmer/solution.html +++ b/programmer/templates/programmer/solution.html @@ -1,29 +1,42 @@ {% 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 }}
-
{{ solution.closing|safe }}
-
- + + {% if not posts %}
@@ -32,15 +45,15 @@
{% endif %} - {% if page_obj.has_next %} -