Добавил прайс страницу
This commit is contained in:
parent
8ae6e4c4f8
commit
2a93cd8a8f
@ -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')
|
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:
|
if not DEBUG:
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
@ -113,6 +117,7 @@ INSTALLED_APPS = [
|
|||||||
'products',
|
'products',
|
||||||
'background_task',
|
'background_task',
|
||||||
'django_celery_results',
|
'django_celery_results',
|
||||||
|
'prices',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@ -27,6 +27,7 @@ urlpatterns = [
|
|||||||
path('ckeditor5/', include('django_ckeditor_5.urls')),
|
path('ckeditor5/', include('django_ckeditor_5.urls')),
|
||||||
path('captcha/', include('captcha.urls')),
|
path('captcha/', include('captcha.urls')),
|
||||||
path('products/', include('products.urls', namespace='products')),
|
path('products/', include('products.urls', namespace='products')),
|
||||||
|
path('prices/', include('prices.urls', namespace='prices')),
|
||||||
# path('', index, name='home'),
|
# path('', index, name='home'),
|
||||||
# path('about/', about, name='about'),
|
# path('about/', about, name='about'),
|
||||||
# path('solution/', solution, name='solution'),
|
# 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} ₽"
|
||||||
54
prices/templates/prices/price_list.html
Normal file
54
prices/templates/prices/price_list.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "programmer/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
</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 %}
|
||||||
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'),
|
||||||
|
]
|
||||||
76
prices/views.py
Normal file
76
prices/views.py
Normal file
@ -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
|
||||||
@ -21,6 +21,7 @@ class MenuContextMixin(ContextMixin):
|
|||||||
{'title': "Кейсы", 'url_name': 'solution'},
|
{'title': "Кейсы", 'url_name': 'solution'},
|
||||||
{'title': "Статьи", 'url_name': 'blog:article_list'},
|
{'title': "Статьи", 'url_name': 'blog:article_list'},
|
||||||
{'title': "Продукты", 'url_name': 'products:product_list'},
|
{'title': "Продукты", 'url_name': 'products:product_list'},
|
||||||
|
{'title': "Прайс на услуги", 'url_name': 'prices:price_list'},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{'title': "Отзывы", 'url_name': 'recall'},
|
{'title': "Отзывы", 'url_name': 'recall'},
|
||||||
|
|||||||
@ -15,4 +15,5 @@ celery
|
|||||||
django-background-tasks
|
django-background-tasks
|
||||||
celery>=5.3.0
|
celery>=5.3.0
|
||||||
redis>=5.0.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