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)