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 %} + + + + + + + {% 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 %} + +{% 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 %}
@@ -438,7 +438,7 @@ {{ callback_form.question }} - {% if form.captcha %} + {% if callback_form.captcha %}
{{ callback_form.captcha }} 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