mod: catalog_profile и документация
This commit is contained in:
@@ -213,6 +213,7 @@ python manage.py collectstatic # собрать статику для
|
|||||||
5. **Foreign Key ON_DELETE**: используется в основном `DO_NOTHING` и `SET_NULL`, будь осторожен при удалении
|
5. **Foreign Key ON_DELETE**: используется в основном `DO_NOTHING` и `SET_NULL`, будь осторожен при удалении
|
||||||
6. **Двойной хост**: убедись, что используешь правильные переменные из `my_secret.py` для текущей машины
|
6. **Двойной хост**: убедись, что используешь правильные переменные из `my_secret.py` для текущей машины
|
||||||
7. **Индексирование БД**: большинство полей для поиска уже имеют `db_index=True`, но проверь при добавлении фильтров
|
7. **Индексирование БД**: большинство полей для поиска уже имеют `db_index=True`, но проверь при добавлении фильтров
|
||||||
|
8. **SEO-даты и свежесть контента**: при переделке вьюх/шаблонов отдельно проверяй, нужны ли ещё `last_update`, `PUB_DAT`, `Date4Meta` и `Last4Meta`; если дата не участвует в смысловой логике страницы, лучше оставить базовые `{% now %}` из `base.html`, а не тащить лишний контекст во вьюху и не нагружать бекенд.
|
||||||
|
|
||||||
## Реферальные ссылки (для более глубокого изучения)
|
## Реферальные ссылки (для более глубокого изучения)
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,11 +1,24 @@
|
|||||||
# Оконный агрегатор «Окнардия»
|
# Оконный агрегатор «Окнардия»
|
||||||
### Переделка под Python 3.12 и Django 5.2
|
### Переделка под Python 3.12 и Django 5.2
|
||||||
|
|
||||||
### Актуальная памятка и дорожная карта
|
### Актуальная памятка дорожная карта
|
||||||
|
|
||||||
|
Готово:
|
||||||
|
|
||||||
* Изменена база данных используемая в проекте (SQLite вместо MariaDB).
|
* Изменена база данных используемая в проекте (SQLite вместо MariaDB).
|
||||||
* Окружение проекта теперь настраивается через `poetry` вместо `pip` и `requirements.txt`.
|
* Окружение проекта теперь настраивается через `poetry` вместо `pip` и `requirements.txt`.
|
||||||
* Проект получает настройки и секреты через переменные окружения (`.env`) вместо `my_secret*.py`.
|
* Проект получает настройки и секреты через переменные окружения (`.env`) вместо `my_secret*.py`.
|
||||||
|
* Изменено создание `sitemap.xml` (raw ⟶ ORM, и теперь через Django-команду `generate_sitemaps` ).
|
||||||
|
* Рефакторинг URL `/catalog/profil/` (raw SQL ⟶ ORM, убран `last_update`, измененs SEO `description` и `keywords`).
|
||||||
|
*
|
||||||
|
|
||||||
|
Планы:
|
||||||
|
* Переделать все raw SQL-запросы на ORM (для перехода на SQLite и для лучшей поддержки разных СУБД в будущем).
|
||||||
|
* Для легаси-страниц (шаблоны и вьюхи) поэтапно проверять (если нужно убирать) старые SEO-хвосты вроде `last_update` / `PUB_DAT` / `Date4Meta` / `Last4Meta`: если дата не несёт смысловой нагрузки, лучше оставлять базовые `{% now %}` из `base.html`, а не тащить лишний контекст во вьюху.
|
||||||
|
* Шаблоны `report/report_last_user_visit.html` и `report/report_log_user_visit.html` сделать с конентом
|
||||||
|
подгружаемым через AJAX (использовать HTMX, напрмемер) и убрать вызовы `get_last_user_visit_list` и `get_last_all_user_visit_list` их соответствующих вьюх. Это должно разгрузить бекенд и, возможно, сделать кеширование.
|
||||||
|
* Упаковать всё в контейнеры (Django + Gunicorn + WhiteNoise...
|
||||||
|
|
||||||
|
|
||||||
См. также:
|
См. также:
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,9 @@
|
|||||||
|
|
||||||
{% block Add_Body_Attribute %} style="padding-top:70px;"{% endblock %}
|
{% block Add_Body_Attribute %} style="padding-top:70px;"{% endblock %}
|
||||||
|
|
||||||
{% block Description %}Каталог оконных профилей{% endblock %}
|
{% block Description %}Подберите оконный профиль под свои требования: в каталоге «Окнардии» собраны производители, марки и ключевые характеристики.{% endblock %}
|
||||||
|
|
||||||
{% block Keywords %}каталог оконных профилей, каталог производителей оконных профилей, каталог профилей, оконные профили, oknardia, окнардия {{ META_KEYWORDS|default:"" }} {% endblock %}
|
{% block Keywords %}оконные профили, каталог профилей, сравнение профилей, производители оконных профилей, характеристики оконных профилей, oknardia {{ META_KEYWORDS|default:"" }} {% endblock %}
|
||||||
|
|
||||||
{% block Date4Meta %}{{ CATALOG_LAST_UPDATE|date:"c" }}{% endblock %}
|
|
||||||
|
|
||||||
{% block Last4Meta %}{{ CATALOG_LAST_UPDATE|date:"c" }}{% endblock %}
|
|
||||||
|
|
||||||
{% block Author4Meta %}: Каталог{% endblock %}
|
{% block Author4Meta %}: Каталог{% endblock %}
|
||||||
|
|
||||||
@@ -27,7 +23,7 @@
|
|||||||
<li>Оконные профили</li>
|
<li>Оконные профили</li>
|
||||||
</ol>
|
</ol>
|
||||||
<h1>Каталог оконных профилей</h1>
|
<h1>Каталог оконных профилей</h1>
|
||||||
<p>Узнать о производителях, познакомиться с детальными характеристики и описаниями оконных профилей можно кликнув по ссылкам. Сейчас в каталоге «Окнардии» представлено {{ CATALOG_MANUFACT_NUM_W }} профилей ({{ CATALOG_PROFILE_NUM }} в базе). Последнее обновление {{ CATALOG_LAST_UPDATE_W }}.</p>
|
<p>Узнать о производителях, познакомиться с детальными характеристики и описаниями оконных профилей можно кликнув по ссылкам. Сейчас в каталоге «Окнардии» представлено {{ CATALOG_MANUFACT_NUM_W }} профилей ({{ CATALOG_PROFILE_NUM }} в базе).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>{# <!--- Хлебные крошки: КОНЕЦ ---> #}
|
</div>{# <!--- Хлебные крошки: КОНЕЦ ---> #}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -45,65 +45,52 @@ def catalog_profile(request: HttpRequest) -> HttpResponse:
|
|||||||
:return response: HttpResponse -- исходящий http-ответ
|
:return response: HttpResponse -- исходящий http-ответ
|
||||||
"""
|
"""
|
||||||
time_start = time.time()
|
time_start = time.time()
|
||||||
q_profile = PVCprofiles.objects.raw('SELECT'
|
# Берём только те поля, которые реально нужны для построения страницы каталога.
|
||||||
' oknardia_pvcprofiles.id,'
|
# Это позволяет не тащить лишние данные из БД и сразу работать с простыми словарями.
|
||||||
' oknardia_pvcprofiles.sProfileName,'
|
profile_rows = list(
|
||||||
' oknardia_pvcprofiles.sProfileBriefDescription,'
|
PVCprofiles.objects.values(
|
||||||
' oknardia_pvcprofiles.sProfileManufacturer,'
|
"id",
|
||||||
' oknardia_catalog2profile.sCatalogCardType,'
|
"sProfileName",
|
||||||
' oknardia_blogposts.sPostContent,'
|
"sProfileBriefDescription",
|
||||||
' oknardia_blogposts.sPostHeader,'
|
"sProfileManufacturer",
|
||||||
'oknardia_pvcprofiles.dProfileModify,'
|
).order_by("sProfileManufacturer", "sProfileBriefDescription")
|
||||||
'MAX(oknardia_blogposts.dPostDataModify) AS lastBlog '
|
)
|
||||||
'FROM oknardia_catalog2profile'
|
profile_count = len(profile_rows)
|
||||||
' RIGHT OUTER JOIN oknardia_pvcprofiles'
|
to_template = {
|
||||||
' ON oknardia_catalog2profile.kProfile_id = oknardia_pvcprofiles.id'
|
'CATALOG_PROFILE_NUM': pytils.numeral.get_plural(profile_count, "профиль,профиля,профилей")
|
||||||
' LEFT OUTER JOIN oknardia_blogposts'
|
}
|
||||||
' ON oknardia_catalog2profile.kBlogCatalog_id = oknardia_blogposts.id '
|
# Локальный помощник: slug нужен несколько раз, а повторять одну и ту же строку не хочется.
|
||||||
'GROUP BY oknardia_catalog2profile.sCatalogCardType,'
|
def make_slug(value: str) -> str:
|
||||||
' oknardia_pvcprofiles.sProfileName,'
|
return pytils.translit.slugify(value).lower()
|
||||||
' oknardia_pvcprofiles.id,'
|
|
||||||
' oknardia_pvcprofiles.sProfileBriefDescription,'
|
|
||||||
' oknardia_pvcprofiles.sProfileManufacturer,'
|
|
||||||
' oknardia_blogposts.sPostHeader,'
|
|
||||||
' oknardia_blogposts.sPostContent,'
|
|
||||||
' oknardia_pvcprofiles.dProfileModify '
|
|
||||||
'ORDER BY oknardia_pvcprofiles.sProfileManufacturer,'
|
|
||||||
' oknardia_pvcprofiles.sProfileBriefDescription;')
|
|
||||||
to_template = {'CATALOG_PROFILE_NUM': pytils.numeral.get_plural(len(list(q_profile)), "профиль,профиля,профилей")}
|
|
||||||
list_profile_manufactures = []
|
list_profile_manufactures = []
|
||||||
tmp_profile_manufacture = ""
|
tmp_profile_manufacture = ""
|
||||||
last_update = None
|
for profile in profile_rows:
|
||||||
for i in q_profile:
|
if profile["sProfileManufacturer"] == "":
|
||||||
if last_update is None:
|
# Пустой производитель в каталоге только мешает: не создаём для него отдельную группу.
|
||||||
last_update = i.dProfileModify
|
continue
|
||||||
if last_update < i.dProfileModify:
|
|
||||||
last_update = i.dProfileModify
|
if tmp_profile_manufacture != profile["sProfileManufacturer"]:
|
||||||
# if (i.lastBlog is not None) and (last_update < i.lastBlog):
|
# Новый производитель — открываем новую группу карточек.
|
||||||
# last_update = i.lastBlog
|
tmp_profile_manufacture = profile["sProfileManufacturer"]
|
||||||
if tmp_profile_manufacture != i.sProfileManufacturer:
|
|
||||||
tmp_profile_manufacture = i.sProfileManufacturer
|
|
||||||
list_profile_manufactures.append({
|
list_profile_manufactures.append({
|
||||||
"PROF_MAN_ID": i.id,
|
"PROF_MAN_ID": profile["id"],
|
||||||
"PROF_MAN": i.sProfileManufacturer,
|
"PROF_MAN": profile["sProfileManufacturer"],
|
||||||
"PROF_MAN_T": pytils.translit.slugify(i.sProfileManufacturer).lower(),
|
"PROF_MAN_T": make_slug(profile["sProfileManufacturer"]),
|
||||||
"PROF_MAN_LIST": [{
|
"PROF_MAN_LIST": [{
|
||||||
"PROF_NAME_ID": i.id,
|
"PROF_NAME_ID": profile["id"],
|
||||||
"PROF_NAME": i.sProfileBriefDescription,
|
"PROF_NAME": profile["sProfileBriefDescription"],
|
||||||
"PROF_NAME_T": pytils.translit.slugify(i.sProfileName).lower(),
|
"PROF_NAME_T": make_slug(profile["sProfileName"]),
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
# print("===", i.sProfileManufacturer, ">>> >>> >>>", Rus2Url(i.sProfileManufacturer))
|
|
||||||
elif len(list_profile_manufactures) == 0:
|
|
||||||
# Какая-то фигня. Похоже "пустой" производитель профиля (пустая строка). Ну его нафиг.
|
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
|
# Если производитель уже встречался, просто дописываем новую модель в его список.
|
||||||
list_profile_manufactures[-1]["PROF_MAN_LIST"].append({
|
list_profile_manufactures[-1]["PROF_MAN_LIST"].append({
|
||||||
"PROF_NAME_ID": i.id,
|
"PROF_NAME_ID": profile["id"],
|
||||||
"PROF_NAME": i.sProfileBriefDescription,
|
"PROF_NAME": profile["sProfileBriefDescription"],
|
||||||
"PROF_NAME_T": pytils.translit.slugify(i.sProfileName).lower(),
|
"PROF_NAME_T": make_slug(profile["sProfileName"]),
|
||||||
})
|
})
|
||||||
# print(\"--- ---", i.sProfileBriefDescription, ">>>", Rus2Url(i.sProfileBriefDescription))
|
|
||||||
to_template.update({
|
to_template.update({
|
||||||
'CATALOG_PROFILE_MAN1_NAME2': list_profile_manufactures,
|
'CATALOG_PROFILE_MAN1_NAME2': list_profile_manufactures,
|
||||||
'CATALOG_MANUFACT_NUM': len(list_profile_manufactures),
|
'CATALOG_MANUFACT_NUM': len(list_profile_manufactures),
|
||||||
@@ -111,11 +98,9 @@ def catalog_profile(request: HttpRequest) -> HttpResponse:
|
|||||||
pytils.numeral.sum_string(len(list_profile_manufactures), pytils.numeral.MALE, ("производитель",
|
pytils.numeral.sum_string(len(list_profile_manufactures), pytils.numeral.MALE, ("производитель",
|
||||||
"производителя",
|
"производителя",
|
||||||
"производителей")),
|
"производителей")),
|
||||||
'CATALOG_LAST_UPDATE': last_update,
|
|
||||||
'CATALOG_LAST_UPDATE_W': pytils.dt.distance_of_time_in_words(time.mktime(last_update.timetuple()), accuracy=2),
|
|
||||||
'LAST_VISIT': get_last_user_visit_list(get_last_user_visit_cookies(request)[:3]),
|
'LAST_VISIT': get_last_user_visit_list(get_last_user_visit_cookies(request)[:3]),
|
||||||
'LOG_VISIT': get_last_all_user_visit_list(),
|
'LOG_VISIT': get_last_all_user_visit_list(),
|
||||||
'ticks': float(time.time() - time_start)
|
'ticks': float(time.time() - time_start),
|
||||||
})
|
})
|
||||||
return render(request, "catalog/catalog_of_profiles.html", to_template)
|
return render(request, "catalog/catalog_of_profiles.html", to_template)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,113 @@
|
|||||||
from django.test import TestCase
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
# Create your tests here.
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from oknardia.models import OurUser, PVCprofiles
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogProfileViewTests(TestCase):
|
||||||
|
"""Регрессионные тесты для вьюхи каталога профилей."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
# Базовый пользователь нужен, потому что профиль ссылается на OurUser.
|
||||||
|
django_user = User.objects.create_user(username="tester", password="secret")
|
||||||
|
self.our_user = OurUser.objects.create(kDjangoUser=django_user)
|
||||||
|
|
||||||
|
def _get_context(self, response):
|
||||||
|
"""Достаёт итоговый контекст из ответа тестового клиента."""
|
||||||
|
context = response.context
|
||||||
|
if isinstance(context, list):
|
||||||
|
return context[-1]
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _create_profile(self, *, name: str, brief: str, manufacturer: str, days_ago: int) -> PVCprofiles:
|
||||||
|
"""Создаёт профиль с нужными полями и фиксирует дату изменения вручную."""
|
||||||
|
profile = PVCprofiles.objects.create(
|
||||||
|
sProfileName=name,
|
||||||
|
sProfileBriefDescription=brief,
|
||||||
|
sProfileManufacturer=manufacturer,
|
||||||
|
kProfile2User=self.our_user,
|
||||||
|
fProfileRating=3.5,
|
||||||
|
)
|
||||||
|
# В модели стоит auto_now=True, поэтому после создания дату правим отдельным update.
|
||||||
|
modified_at = timezone.now() - timedelta(days=days_ago)
|
||||||
|
PVCprofiles.objects.filter(pk=profile.pk).update(dProfileModify=modified_at)
|
||||||
|
profile.refresh_from_db()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
@patch("web.catalog.get_last_all_user_visit_list", return_value=["all-visits"])
|
||||||
|
@patch("web.catalog.get_last_user_visit_list", return_value=["last-visits"])
|
||||||
|
@patch("web.catalog.get_last_user_visit_cookies", return_value=["cookie-1", "cookie-2", "cookie-3"])
|
||||||
|
def test_catalog_profile_handles_empty_catalog(
|
||||||
|
self,
|
||||||
|
mocked_cookies,
|
||||||
|
mocked_last_visits,
|
||||||
|
mocked_all_visits,
|
||||||
|
):
|
||||||
|
"""Пустой каталог не должен падать и должен отдавать ожидаемый контекст."""
|
||||||
|
with self.assertNumQueries(1):
|
||||||
|
response = self.client.get("/catalog/profile/")
|
||||||
|
|
||||||
|
context = self._get_context(response)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(context["CATALOG_PROFILE_NUM"], "0 профилей")
|
||||||
|
self.assertEqual(context["CATALOG_MANUFACT_NUM"], 0)
|
||||||
|
self.assertEqual(context["CATALOG_PROFILE_MAN1_NAME2"], [])
|
||||||
|
self.assertEqual(context["LAST_VISIT"], ["last-visits"])
|
||||||
|
self.assertEqual(context["LOG_VISIT"], ["all-visits"])
|
||||||
|
self.assertTrue(mocked_cookies.called)
|
||||||
|
self.assertTrue(mocked_last_visits.called)
|
||||||
|
self.assertTrue(mocked_all_visits.called)
|
||||||
|
|
||||||
|
@patch("web.catalog.get_last_all_user_visit_list", return_value=[])
|
||||||
|
@patch("web.catalog.get_last_user_visit_list", return_value=[])
|
||||||
|
@patch("web.catalog.get_last_user_visit_cookies", return_value=[])
|
||||||
|
def test_catalog_profile_groups_and_sorts_profiles(
|
||||||
|
self,
|
||||||
|
mocked_cookies,
|
||||||
|
mocked_last_visits,
|
||||||
|
mocked_all_visits,
|
||||||
|
):
|
||||||
|
"""Каталог должен группировать профили по производителю и сохранять сортировку."""
|
||||||
|
self._create_profile(name="Alpha Basic", brief="Альфа База", manufacturer="Альфа", days_ago=5)
|
||||||
|
self._create_profile(name="Alpha Plus", brief="Альфа Плюс", manufacturer="Альфа", days_ago=2)
|
||||||
|
self._create_profile(name="Beta Light", brief="Бета Лайт", manufacturer="Бета", days_ago=1)
|
||||||
|
self._create_profile(name="Hidden", brief="Скрытый", manufacturer="", days_ago=7)
|
||||||
|
|
||||||
|
with self.assertNumQueries(1):
|
||||||
|
response = self.client.get("/catalog/profile/")
|
||||||
|
|
||||||
|
context = self._get_context(response)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Пустой производитель не должен превращаться в отдельную группу.
|
||||||
|
groups = context["CATALOG_PROFILE_MAN1_NAME2"]
|
||||||
|
self.assertEqual(len(groups), 2)
|
||||||
|
self.assertEqual([group["PROF_MAN"] for group in groups], ["Альфа", "Бета"])
|
||||||
|
|
||||||
|
alpha_group = groups[0]
|
||||||
|
self.assertEqual(alpha_group["PROF_MAN_T"], "alfa")
|
||||||
|
self.assertEqual(
|
||||||
|
[item["PROF_NAME"] for item in alpha_group["PROF_MAN_LIST"]],
|
||||||
|
["Альфа База", "Альфа Плюс"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[item["PROF_NAME_T"] for item in alpha_group["PROF_MAN_LIST"]],
|
||||||
|
["alpha-basic", "alpha-plus"],
|
||||||
|
)
|
||||||
|
|
||||||
|
beta_group = groups[1]
|
||||||
|
self.assertEqual(beta_group["PROF_MAN_T"], "beta")
|
||||||
|
self.assertEqual([item["PROF_NAME"] for item in beta_group["PROF_MAN_LIST"]], ["Бета Лайт"])
|
||||||
|
|
||||||
|
# Проверяем итоговые счетчики и структуру контекста.
|
||||||
|
self.assertEqual(context["CATALOG_MANUFACT_NUM"], 2)
|
||||||
|
self.assertEqual(context["CATALOG_PROFILE_NUM"], "4 профиля")
|
||||||
|
self.assertEqual(context["LAST_VISIT"], [])
|
||||||
|
self.assertEqual(context["LOG_VISIT"], [])
|
||||||
|
self.assertTrue(mocked_cookies.called)
|
||||||
|
self.assertTrue(mocked_last_visits.called)
|
||||||
|
self.assertTrue(mocked_all_visits.called)
|
||||||
|
|||||||
Reference in New Issue
Block a user