From 2a93cd8a8fc4518d882a93a5a2875d14dfa6dfd6 Mon Sep 17 00:00:00 2001 From: NikDizell Date: Mon, 13 Jul 2026 22:37:41 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B9=D1=81=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OneCprogsite/settings.py | 5 ++ OneCprogsite/urls.py | 1 + prices/__init__.py | 0 prices/admin.py | 35 ++++++++++++ prices/apps.py | 6 ++ prices/migrations/__init__.py | 0 prices/models.py | 15 +++++ prices/templates/prices/price_list.html | 54 ++++++++++++++++++ prices/tests.py | 3 + prices/urls.py | 9 +++ prices/views.py | 76 +++++++++++++++++++++++++ programmer/mixins.py | 1 + requirements.txt | 3 +- 13 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 prices/__init__.py create mode 100644 prices/admin.py create mode 100644 prices/apps.py create mode 100644 prices/migrations/__init__.py create mode 100644 prices/models.py create mode 100644 prices/templates/prices/price_list.html create mode 100644 prices/tests.py create mode 100644 prices/urls.py create mode 100644 prices/views.py diff --git a/OneCprogsite/settings.py b/OneCprogsite/settings.py index a501465..70f21e1 100644 --- a/OneCprogsite/settings.py +++ b/OneCprogsite/settings.py @@ -50,6 +50,10 @@ EXCLUDED_IPS = os.getenv('EXCLUDED_IPS', 'localhost,127.0.0.1').split(',') SITE_URL = os.getenv('SITE_URL', 'https://nikdizell.ru') +PRICE_API_URL = os.getenv('PRICE_API_URL', 'http://localhost:81/BP/hs/webexchange/price') +PRICE_API_USER = os.getenv('PRICE_API_USER', '') +PRICE_API_PASSWORD = os.getenv('PRICE_API_PASSWORD', '') + # Важно для работы за прокси if not DEBUG: SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') @@ -113,6 +117,7 @@ INSTALLED_APPS = [ 'products', 'background_task', 'django_celery_results', + 'prices', ] MIDDLEWARE = [ diff --git a/OneCprogsite/urls.py b/OneCprogsite/urls.py index df8c438..db6fd3e 100644 --- a/OneCprogsite/urls.py +++ b/OneCprogsite/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ path('ckeditor5/', include('django_ckeditor_5.urls')), path('captcha/', include('captcha.urls')), path('products/', include('products.urls', namespace='products')), + path('prices/', include('prices.urls', namespace='prices')), # path('', index, name='home'), # path('about/', about, name='about'), # path('solution/', solution, name='solution'), diff --git a/prices/__init__.py b/prices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prices/admin.py b/prices/admin.py new file mode 100644 index 0000000..d0fa277 --- /dev/null +++ b/prices/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin, messages +from django.shortcuts import redirect +from django.urls import path +from .models import PriceItem +from .views import update_prices_from_api + +@admin.register(PriceItem) +class PriceItemAdmin(admin.ModelAdmin): + list_display = ('name', 'price', 'updated_at') + search_fields = ('name',) + actions = ['update_prices_action'] + + def update_prices_action(self, request, queryset): + """Admin action для обновления прайса.""" + update_prices_from_api(request) + update_prices_action.short_description = "Обновить прайс из API" + + def get_urls(self): + """Добавляем кастомный URL для кнопки в changelist.""" + urls = super().get_urls() + custom_urls = [ + path('update-prices/', self.admin_site.admin_view(self.update_prices_view), name='update-prices'), + ] + return custom_urls + urls + + def update_prices_view(self, request): + """View для кнопки обновления.""" + update_prices_from_api(request) + return redirect('admin:prices_priceitem_changelist') + + def changelist_view(self, request, extra_context=None): + """Передаём в контекст наличие кнопки.""" + extra_context = extra_context or {} + extra_context['show_update_button'] = True + return super().changelist_view(request, extra_context=extra_context) \ No newline at end of file diff --git a/prices/apps.py b/prices/apps.py new file mode 100644 index 0000000..821b0fa --- /dev/null +++ b/prices/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PricesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'prices' diff --git a/prices/migrations/__init__.py b/prices/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/prices/models.py b/prices/models.py new file mode 100644 index 0000000..c56f0c4 --- /dev/null +++ b/prices/models.py @@ -0,0 +1,15 @@ +from django.db import models +from django.urls import reverse + +class PriceItem(models.Model): + name = models.CharField(max_length=255, verbose_name='Наименование услуги') + price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='Цена (₽)') + updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') + + class Meta: + verbose_name = 'Услуга' + verbose_name_plural = 'Прайс-лист' + ordering = ['name'] + + def __str__(self): + return f"{self.name} – {self.price} ₽" \ No newline at end of file diff --git a/prices/templates/prices/price_list.html b/prices/templates/prices/price_list.html new file mode 100644 index 0000000..c5bb97b --- /dev/null +++ b/prices/templates/prices/price_list.html @@ -0,0 +1,54 @@ +{% extends "programmer/base.html" %} +{% load static %} + +{% block content %} + + +
+ + + +
+ + + + + + + + + + {% for item in price_items %} + + + + + + {% empty %} + + + + {% endfor %} + +
Наименование услугиЦена, ₽/ч
{{ forloop.counter }}{{ item.name }}{{ item.price|floatformat:0 }}
Прайс-лист временно недоступен
+
+
+ Обновлено: {% if price_items %}{{ price_items.0.updated_at|date:"d.m.Y H:i" }}{% else %}нет данных{% endif %} +
+ {% if user.is_staff %} + + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/prices/tests.py b/prices/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/prices/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/prices/urls.py b/prices/urls.py new file mode 100644 index 0000000..92df12c --- /dev/null +++ b/prices/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .views import PriceListView, update_prices_redirect + +app_name = 'prices' + +urlpatterns = [ + path('', PriceListView.as_view(), name='price_list'), + path('admin/update-prices/', update_prices_redirect, name='admin_update_prices'), +] \ No newline at end of file diff --git a/prices/views.py b/prices/views.py new file mode 100644 index 0000000..5f223d6 --- /dev/null +++ b/prices/views.py @@ -0,0 +1,76 @@ +import json +import base64 +from urllib.request import urlopen, Request +from urllib.error import URLError +from django.contrib import messages +from django.conf import settings +from django.contrib.admin.views.decorators import staff_member_required +from django.shortcuts import redirect +from django.views.generic import ListView +from programmer.mixins import MenuContextMixin, BreadcrumbMixin +from .models import PriceItem + +def update_prices_from_api(request): + """Загружает прайс из API с Basic Auth и обновляет таблицу PriceItem.""" + api_url = getattr(settings, 'PRICE_API_URL', 'http://localhost:81/BP/hs/webexchange/price') + username = getattr(settings, 'PRICE_API_USER', '') + password = getattr(settings, 'PRICE_API_PASSWORD', '') + + # Формируем заголовок Authorization + credentials = f"{username}:{password}" + encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8') + headers = { + 'Authorization': f'Basic {encoded}', + 'Accept': 'application/json', + } + + try: + req = Request(api_url, headers=headers) + with urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode('utf-8')) + except URLError as e: + messages.error(request, f'Ошибка соединения с API: {e}') + return False + except json.JSONDecodeError as e: + messages.error(request, f'Ошибка парсинга JSON: {e}') + return False + + if data.get('error', True): + messages.error(request, 'API вернул ошибку') + return False + + items = data.get('result', []) + if not items: + messages.warning(request, 'API вернул пустой список') + return False + + # Обновляем + PriceItem.objects.all().delete() + objs = [PriceItem(name=item['name'], price=item['price']) for item in items] + PriceItem.objects.bulk_create(objs) + + messages.success(request, f'Прайс обновлён: загружено {len(objs)} услуг') + return True + +@staff_member_required +def update_prices_redirect(request): + update_prices_from_api(request) + return redirect('admin:prices_priceitem_changelist') + +class PriceListView(MenuContextMixin, BreadcrumbMixin, ListView): + model = PriceItem + template_name = 'prices/price_list.html' + context_object_name = 'price_items' + paginate_by = 20 + + def get_breadcrumbs(self): + return [{'title': 'Прайс-лист', 'url_name': None}] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'title': 'Прайс-лист на услуги 1С | С.Н.А. Технологии', + 'meta_description': 'Актуальные цены на услуги программиста 1С: аудит, обновление, разработка, интеграция и другие.', + 'meta_keywords': 'прайс 1С, цены на услуги 1С, стоимость разработки 1С', + }) + return context \ No newline at end of file diff --git a/programmer/mixins.py b/programmer/mixins.py index a3031db..d08dc89 100644 --- a/programmer/mixins.py +++ b/programmer/mixins.py @@ -21,6 +21,7 @@ class MenuContextMixin(ContextMixin): {'title': "Кейсы", 'url_name': 'solution'}, {'title': "Статьи", 'url_name': 'blog:article_list'}, {'title': "Продукты", 'url_name': 'products:product_list'}, + {'title': "Прайс на услуги", 'url_name': 'prices:price_list'}, ] }, {'title': "Отзывы", 'url_name': 'recall'}, diff --git a/requirements.txt b/requirements.txt index 3d5ee78..51ab010 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,5 @@ celery django-background-tasks celery>=5.3.0 redis>=5.0.0 -django-celery-results>=2.5.0 \ No newline at end of file +django-celery-results>=2.5.0 +requests \ No newline at end of file From 7ca185407bee54c040976462aa56c775747c9e1d Mon Sep 17 00:00:00 2001 From: NikDizell Date: Tue, 14 Jul 2026 00:08:48 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D1=83=20=D1=81=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D1=81=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B9=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prices/templates/prices/price_list.html | 12 ++++++++++++ prices/views.py | 13 ++++++++++++- .../static/programmer/js/floating-button.js | 19 +++++++++++++++---- programmer/templates/programmer/base.html | 4 ++-- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/prices/templates/prices/price_list.html b/prices/templates/prices/price_list.html index c5bb97b..f4d0c75 100644 --- a/prices/templates/prices/price_list.html +++ b/prices/templates/prices/price_list.html @@ -1,5 +1,8 @@ {% extends "programmer/base.html" %} +{% load django_bootstrap5 %} {% load static %} +{% load seo_tags %} +{% block body_attrs %}data-show-floating-button="false"{% endblock %} {% block content %} {% endif %} + +{% endblock %} + +{% block extra_js %} + + {% endblock %} \ No newline at end of file diff --git a/prices/views.py b/prices/views.py index 5f223d6..61c4f14 100644 --- a/prices/views.py +++ b/prices/views.py @@ -9,6 +9,9 @@ from django.shortcuts import redirect from django.views.generic import ListView from programmer.mixins import MenuContextMixin, BreadcrumbMixin from .models import PriceItem +from programmer.forms import CallbackForm +from typing import Any, Dict + def update_prices_from_api(request): """Загружает прайс из API с Basic Auth и обновляет таблицу PriceItem.""" @@ -66,8 +69,16 @@ class PriceListView(MenuContextMixin, BreadcrumbMixin, ListView): def get_breadcrumbs(self): return [{'title': 'Прайс-лист', 'url_name': None}] - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: context = super().get_context_data(**kwargs) + form = CallbackForm() + + if self.request.user.is_authenticated: + if 'captcha' in form.fields: + del form.fields['captcha'] + + context['form'] = form + context.update({ 'title': 'Прайс-лист на услуги 1С | С.Н.А. Технологии', 'meta_description': 'Актуальные цены на услуги программиста 1С: аудит, обновление, разработка, интеграция и другие.', diff --git a/programmer/static/programmer/js/floating-button.js b/programmer/static/programmer/js/floating-button.js index b2b3268..728132d 100644 --- a/programmer/static/programmer/js/floating-button.js +++ b/programmer/static/programmer/js/floating-button.js @@ -5,6 +5,17 @@ const CallbackModal = { floatingBtnId: 'floatingButton', init() { + const body = document.body; + const showButton = body.dataset.showFloatingButton !== 'false' && body.dataset.showFloatingButton !== '0'; + const delay = parseInt(body.dataset.floatingDelay, 10) || 5000; + + if (!showButton) { + // Кнопка отключена – скрываем навсегда + const btn = document.getElementById(this.floatingBtnId); + if (btn) btn.style.display = 'none'; + return; + } + setTimeout(() => { const btn = document.getElementById(this.floatingBtnId); if (btn) { @@ -13,7 +24,7 @@ const CallbackModal = { const button = btn.querySelector('.btn'); if (button) button.classList.add('pulse'); } - }, 5000); + }, delay); }, open(productTitle) { @@ -21,9 +32,9 @@ const CallbackModal = { if (modal) { if (productTitle) { - const questionField = modal.querySelector('#id_question'); - if (questionField) { - questionField.value = `Запрос цены по ПП "${productTitle}"`; + const questionField = modal.querySelector('#id_question'); + if (questionField) { + questionField.value = `Запрос цены по ПП "${productTitle}"`; } } diff --git a/programmer/templates/programmer/base.html b/programmer/templates/programmer/base.html index 6fbe985..e9df5bc 100644 --- a/programmer/templates/programmer/base.html +++ b/programmer/templates/programmer/base.html @@ -132,7 +132,7 @@ - + {% block mainmenu %}
@@ -438,7 +438,7 @@ {{ callback_form.question }} - {% if form.captcha %} + {% if callback_form.captcha %}
{{ callback_form.captcha }}