Merge pull request 'fix_test' (#35) from fix_test into main
Some checks failed
Auto-update README / update-readme (push) Has been cancelled
Some checks failed
Auto-update README / update-readme (push) Has been cancelled
Reviewed-on: #35
This commit is contained in:
commit
145b4b7e03
@ -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 = [
|
||||
|
||||
@ -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'),
|
||||
|
||||
0
prices/__init__.py
Normal file
0
prices/__init__.py
Normal file
35
prices/admin.py
Normal file
35
prices/admin.py
Normal file
@ -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)
|
||||
6
prices/apps.py
Normal file
6
prices/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PricesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'prices'
|
||||
0
prices/migrations/__init__.py
Normal file
0
prices/migrations/__init__.py
Normal file
15
prices/models.py
Normal file
15
prices/models.py
Normal file
@ -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} ₽"
|
||||
66
prices/templates/prices/price_list.html
Normal file
66
prices/templates/prices/price_list.html
Normal file
@ -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 %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Прайс-лист на услуги</h1>
|
||||
<p class="page-subtitle">Актуальные цены на услуги программиста 1С</p>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<!-- Информационный блок о ценах -->
|
||||
<div class="alert alert-info mb-4" role="alert">
|
||||
<strong>ℹ️ Обратите внимание!</strong> В прайс-листе указаны <strong>минимальные цены (ставка часа)</strong> на услуги.
|
||||
Итоговая стоимость рассчитывается индивидуально в зависимости от объёма и сложности работ.
|
||||
Для разовых заказов цена может отличаться — пожалуйста, <a href="#" onclick="CallbackModal.open(); return false;" class="alert-link">уточняйте точную стоимость</a> при обращении.
|
||||
При долгосрочном сотрудничестве мы предлагаем гибкие условия и скидки.
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Наименование услуги</th>
|
||||
<th>Цена, ₽/ч</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in price_items %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.price|floatformat:0 }}</td>
|
||||
<td>
|
||||
<a href="#" onclick="CallbackModal.open('{{ item.name }}'); return false;" class="btn btn-sm btn-outline-primary">уточнить</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center">Прайс-лист временно недоступен</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-muted mt-3">
|
||||
<small>Обновлено: {% if price_items %}{{ price_items.0.updated_at|date:"d.m.Y H:i" }}{% else %}нет данных{% endif %}</small>
|
||||
</div>
|
||||
{% if user.is_staff %}
|
||||
<div class="mt-3">
|
||||
<a href="{% url 'prices:admin_update_prices' %}" class="btn btn-secondary btn-sm">
|
||||
🔄 Обновить прайс (админ)
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'programmer/js/infinite_scroll.js' %}" defer></script>
|
||||
<script src="{% static 'programmer/js/floating-button.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
3
prices/tests.py
Normal file
3
prices/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
prices/urls.py
Normal file
9
prices/urls.py
Normal file
@ -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'),
|
||||
]
|
||||
87
prices/views.py
Normal file
87
prices/views.py
Normal file
@ -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
|
||||
@ -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'},
|
||||
|
||||
@ -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}"`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -132,7 +132,7 @@
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<body {% block body_attrs %}{% endblock %}>
|
||||
<!-- Header -->
|
||||
{% block mainmenu %}
|
||||
<header class="header">
|
||||
@ -438,7 +438,7 @@
|
||||
{{ callback_form.question }}
|
||||
</div>
|
||||
|
||||
{% if form.captcha %}
|
||||
{% if callback_form.captcha %}
|
||||
<div class="form-group">
|
||||
<label for="id_captcha">Защитный код *</label>
|
||||
{{ callback_form.captcha }}
|
||||
|
||||
@ -15,4 +15,5 @@ celery
|
||||
django-background-tasks
|
||||
celery>=5.3.0
|
||||
redis>=5.0.0
|
||||
django-celery-results>=2.5.0
|
||||
django-celery-results>=2.5.0
|
||||
requests
|
||||
Loading…
x
Reference in New Issue
Block a user