diff --git a/AGENTS.md b/AGENTS.md index 3c3db46..fb30df5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,6 +213,7 @@ python manage.py collectstatic # собрать статику для 5. **Foreign Key ON_DELETE**: используется в основном `DO_NOTHING` и `SET_NULL`, будь осторожен при удалении 6. **Двойной хост**: убедись, что используешь правильные переменные из `my_secret.py` для текущей машины 7. **Индексирование БД**: большинство полей для поиска уже имеют `db_index=True`, но проверь при добавлении фильтров +8. **SEO-даты и свежесть контента**: при переделке вьюх/шаблонов отдельно проверяй, нужны ли ещё `last_update`, `PUB_DAT`, `Date4Meta` и `Last4Meta`; если дата не участвует в смысловой логике страницы, лучше оставить базовые `{% now %}` из `base.html`, а не тащить лишний контекст во вьюху и не нагружать бекенд. ## Реферальные ссылки (для более глубокого изучения) diff --git a/README.md b/README.md index b5f7213..311a9ff 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,24 @@ # Оконный агрегатор «Окнардия» ### Переделка под Python 3.12 и Django 5.2 -### Актуальная памятка и дорожная карта +### Актуальная памятка дорожная карта + +Готово: * Изменена база данных используемая в проекте (SQLite вместо MariaDB). * Окружение проекта теперь настраивается через `poetry` вместо `pip` и `requirements.txt`. * Проект получает настройки и секреты через переменные окружения (`.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... + См. также: diff --git a/oknardia/templates/catalog/catalog_of_profiles.html b/oknardia/templates/catalog/catalog_of_profiles.html index fcb83d6..152d9af 100755 --- a/oknardia/templates/catalog/catalog_of_profiles.html +++ b/oknardia/templates/catalog/catalog_of_profiles.html @@ -5,13 +5,9 @@ {% block Add_Body_Attribute %} style="padding-top:70px;"{% endblock %} -{% block Description %}Каталог оконных профилей{% endblock %} +{% block Description %}Подберите оконный профиль под свои требования: в каталоге «Окнардии» собраны производители, марки и ключевые характеристики.{% 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 Keywords %}оконные профили, каталог профилей, сравнение профилей, производители оконных профилей, характеристики оконных профилей, oknardia {{ META_KEYWORDS|default:"" }} {% endblock %} {% block Author4Meta %}: Каталог{% endblock %} @@ -27,7 +23,7 @@
  • Оконные профили
  • Каталог оконных профилей

    -

    Узнать о производителях, познакомиться с детальными характеристики и описаниями оконных профилей можно кликнув по ссылкам. Сейчас в каталоге «Окнардии» представлено {{ CATALOG_MANUFACT_NUM_W }} профилей ({{ CATALOG_PROFILE_NUM }} в базе). Последнее обновление {{ CATALOG_LAST_UPDATE_W }}.

    +

    Узнать о производителях, познакомиться с детальными характеристики и описаниями оконных профилей можно кликнув по ссылкам. Сейчас в каталоге «Окнардии» представлено {{ CATALOG_MANUFACT_NUM_W }} профилей ({{ CATALOG_PROFILE_NUM }} в базе).

    {# #}
    diff --git a/oknardia/web/catalog.py b/oknardia/web/catalog.py index 12f7182..820b7fa 100644 --- a/oknardia/web/catalog.py +++ b/oknardia/web/catalog.py @@ -45,65 +45,52 @@ def catalog_profile(request: HttpRequest) -> HttpResponse: :return response: HttpResponse -- исходящий http-ответ """ time_start = time.time() - q_profile = PVCprofiles.objects.raw('SELECT' - ' oknardia_pvcprofiles.id,' - ' oknardia_pvcprofiles.sProfileName,' - ' oknardia_pvcprofiles.sProfileBriefDescription,' - ' oknardia_pvcprofiles.sProfileManufacturer,' - ' oknardia_catalog2profile.sCatalogCardType,' - ' oknardia_blogposts.sPostContent,' - ' oknardia_blogposts.sPostHeader,' - 'oknardia_pvcprofiles.dProfileModify,' - 'MAX(oknardia_blogposts.dPostDataModify) AS lastBlog ' - 'FROM oknardia_catalog2profile' - ' RIGHT OUTER JOIN oknardia_pvcprofiles' - ' ON oknardia_catalog2profile.kProfile_id = oknardia_pvcprofiles.id' - ' LEFT OUTER JOIN oknardia_blogposts' - ' ON oknardia_catalog2profile.kBlogCatalog_id = oknardia_blogposts.id ' - 'GROUP BY oknardia_catalog2profile.sCatalogCardType,' - ' oknardia_pvcprofiles.sProfileName,' - ' 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)), "профиль,профиля,профилей")} + # Берём только те поля, которые реально нужны для построения страницы каталога. + # Это позволяет не тащить лишние данные из БД и сразу работать с простыми словарями. + profile_rows = list( + PVCprofiles.objects.values( + "id", + "sProfileName", + "sProfileBriefDescription", + "sProfileManufacturer", + ).order_by("sProfileManufacturer", "sProfileBriefDescription") + ) + profile_count = len(profile_rows) + to_template = { + 'CATALOG_PROFILE_NUM': pytils.numeral.get_plural(profile_count, "профиль,профиля,профилей") + } + # Локальный помощник: slug нужен несколько раз, а повторять одну и ту же строку не хочется. + def make_slug(value: str) -> str: + return pytils.translit.slugify(value).lower() + list_profile_manufactures = [] tmp_profile_manufacture = "" - last_update = None - for i in q_profile: - if last_update is None: - last_update = i.dProfileModify - if last_update < i.dProfileModify: - last_update = i.dProfileModify - # if (i.lastBlog is not None) and (last_update < i.lastBlog): - # last_update = i.lastBlog - if tmp_profile_manufacture != i.sProfileManufacturer: - tmp_profile_manufacture = i.sProfileManufacturer + for profile in profile_rows: + if profile["sProfileManufacturer"] == "": + # Пустой производитель в каталоге только мешает: не создаём для него отдельную группу. + continue + + if tmp_profile_manufacture != profile["sProfileManufacturer"]: + # Новый производитель — открываем новую группу карточек. + tmp_profile_manufacture = profile["sProfileManufacturer"] list_profile_manufactures.append({ - "PROF_MAN_ID": i.id, - "PROF_MAN": i.sProfileManufacturer, - "PROF_MAN_T": pytils.translit.slugify(i.sProfileManufacturer).lower(), + "PROF_MAN_ID": profile["id"], + "PROF_MAN": profile["sProfileManufacturer"], + "PROF_MAN_T": make_slug(profile["sProfileManufacturer"]), "PROF_MAN_LIST": [{ - "PROF_NAME_ID": i.id, - "PROF_NAME": i.sProfileBriefDescription, - "PROF_NAME_T": pytils.translit.slugify(i.sProfileName).lower(), + "PROF_NAME_ID": profile["id"], + "PROF_NAME": profile["sProfileBriefDescription"], + "PROF_NAME_T": make_slug(profile["sProfileName"]), }] }) - # print("===", i.sProfileManufacturer, ">>> >>> >>>", Rus2Url(i.sProfileManufacturer)) - elif len(list_profile_manufactures) == 0: - # Какая-то фигня. Похоже "пустой" производитель профиля (пустая строка). Ну его нафиг. - continue else: + # Если производитель уже встречался, просто дописываем новую модель в его список. list_profile_manufactures[-1]["PROF_MAN_LIST"].append({ - "PROF_NAME_ID": i.id, - "PROF_NAME": i.sProfileBriefDescription, - "PROF_NAME_T": pytils.translit.slugify(i.sProfileName).lower(), + "PROF_NAME_ID": profile["id"], + "PROF_NAME": profile["sProfileBriefDescription"], + "PROF_NAME_T": make_slug(profile["sProfileName"]), }) - # print(\"--- ---", i.sProfileBriefDescription, ">>>", Rus2Url(i.sProfileBriefDescription)) + to_template.update({ 'CATALOG_PROFILE_MAN1_NAME2': 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, ("производитель", "производителя", "производителей")), - '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]), '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) diff --git a/oknardia/web/tests.py b/oknardia/web/tests.py index 7ce503c..7ff85b3 100644 --- a/oknardia/web/tests.py +++ b/oknardia/web/tests.py @@ -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)