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..f4d0c75
--- /dev/null
+++ b/prices/templates/prices/price_list.html
@@ -0,0 +1,66 @@
+{% extends "programmer/base.html" %}
+{% load django_bootstrap5 %}
+{% load static %}
+{% load seo_tags %}
+{% block body_attrs %}data-show-floating-button="false"{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
ℹ️ Обратите внимание! В прайс-листе указаны
минимальные цены (ставка часа) на услуги.
+ Итоговая стоимость рассчитывается индивидуально в зависимости от объёма и сложности работ.
+ Для разовых заказов цена может отличаться — пожалуйста,
уточняйте точную стоимость при обращении.
+ При долгосрочном сотрудничестве мы предлагаем гибкие условия и скидки.
+
+
+
+
+
+
+ | № |
+ Наименование услуги |
+ Цена, ₽/ч |
+
+
+
+ {% for item in price_items %}
+
+ | {{ forloop.counter }} |
+ {{ item.name }} |
+ {{ item.price|floatformat:0 }} |
+
+ уточнить
+ |
+
+ {% empty %}
+
+ | Прайс-лист временно недоступен |
+
+ {% endfor %}
+
+
+
+
+ Обновлено: {% if price_items %}{{ price_items.0.updated_at|date:"d.m.Y H:i" }}{% else %}нет данных{% endif %}
+
+ {% if user.is_staff %}
+
+ {% endif %}
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+
+{% 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..61c4f14
--- /dev/null
+++ b/prices/views.py
@@ -0,0 +1,87 @@
+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
+from programmer.forms import CallbackForm
+from typing import Any, Dict
+
+
+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: 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С: аудит, обновление, разработка, интеграция и другие.',
+ '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/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 %}