From e2d5ad6d24d01770c259fc4c582992a061a038bd Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 4 Oct 2026 18:47:39 +0300 Subject: [PATCH 1/2] fix(projects): persist a default name for unnamed projects --- .../migrations/0034_default_project_name.py | 26 ++++ projects/models.py | 13 +- projects/names.py | 6 + projects/tests/test_project_default_name.py | 124 ++++++++++++++++++ projects/tests/test_project_workspace_api.py | 2 + projects/validators.py | 5 + 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 projects/migrations/0034_default_project_name.py create mode 100644 projects/names.py create mode 100644 projects/tests/test_project_default_name.py diff --git a/projects/migrations/0034_default_project_name.py b/projects/migrations/0034_default_project_name.py new file mode 100644 index 00000000..891878ac --- /dev/null +++ b/projects/migrations/0034_default_project_name.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +def fill_unnamed_projects(apps, schema_editor): + Project = apps.get_model("projects", "Project") + Project.objects.using(schema_editor.connection.alias).filter( + models.Q(name__isnull=True) | models.Q(name__regex=r"^\s*$") + ).update(name="Проект без названия") + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0033_delete_projectnews"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="name", + field=models.CharField( + blank=True, default="Проект без названия", max_length=256, null=True + ), + ), + # Preserve assigned names on rollback; they may have been chosen by a user. + migrations.RunPython(fill_unnamed_projects, migrations.RunPython.noop), + ] diff --git a/projects/models.py b/projects/models.py index a8d9833f..044c128b 100644 --- a/projects/models.py +++ b/projects/models.py @@ -14,6 +14,7 @@ from files.models import UserFile from industries.models import Industry from projects.managers import AchievementManager, CollaboratorManager, ProjectManager +from projects.names import DEFAULT_PROJECT_NAME, normalize_project_name from projects.validators import inn_validator from users.models import CustomUser @@ -90,7 +91,9 @@ class Project(models.Model): datetime_updated (DateTimeField): Дата последнего изменения проекта. """ - name = models.CharField(max_length=256, null=True, blank=True) + name = models.CharField( + max_length=256, null=True, blank=True, default=DEFAULT_PROJECT_NAME + ) description = models.TextField(null=True, blank=True) region = models.CharField(max_length=256, null=True, blank=True) hidden_score = models.PositiveSmallIntegerField(default=100) @@ -204,7 +207,13 @@ def __str__(self): return f"Project<{self.id}> - {self.name}" def save(self, *args, **kwargs): - """Set random cover and avatar images if not provided.""" + """Set a default name, cover and avatar when not provided.""" + normalized_name = normalize_project_name(self.name) + if self.name != normalized_name: + self.name = normalized_name + if kwargs.get("update_fields"): + kwargs["update_fields"] = {*kwargs["update_fields"], "name"} + if not self.cover_image_address: self.cover_image_address = DefaultProjectCover.get_random_file_link() diff --git a/projects/names.py b/projects/names.py new file mode 100644 index 00000000..05b9e440 --- /dev/null +++ b/projects/names.py @@ -0,0 +1,6 @@ +DEFAULT_PROJECT_NAME = "Проект без названия" + + +def normalize_project_name(name: str | None) -> str: + """Give unnamed projects a searchable name without changing existing titles.""" + return name if name and name.strip() else DEFAULT_PROJECT_NAME diff --git a/projects/tests/test_project_default_name.py b/projects/tests/test_project_default_name.py new file mode 100644 index 00000000..02271443 --- /dev/null +++ b/projects/tests/test_project_default_name.py @@ -0,0 +1,124 @@ +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import patch + +from django.apps import apps +from django.db import connection +from django.test import TestCase +from rest_framework.test import APIClient + +from projects.models import Project +from projects.names import DEFAULT_PROJECT_NAME + +from .helpers import create_user + + +class ProjectDefaultNameTests(TestCase): + def setUp(self): + self.user = create_user(prefix="default-project-name") + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_model_creation_defaults_missing_and_blank_names(self): + for payload in ({}, {"name": None}, {"name": ""}, {"name": " \t\r\n\u00a0"}): + with self.subTest(payload=payload): + project = Project.objects.create(leader=self.user, **payload) + project.refresh_from_db() + self.assertEqual(project.name, DEFAULT_PROJECT_NAME) + + def test_create_api_returns_persisted_name_in_detail_and_leader_list(self): + for payload in ({}, {"name": None}, {"name": ""}, {"name": " \t\n"}): + with self.subTest(payload=payload): + response = self.client.post("/projects/", payload, format="json") + self.assertEqual(response.status_code, 201) + project_id = response.data["id"] + self.assertEqual(response.data["name"], DEFAULT_PROJECT_NAME) + self.assertEqual( + Project.objects.get(pk=project_id).name, DEFAULT_PROJECT_NAME + ) + detail = self.client.get(f"/projects/{project_id}/") + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail.data["name"], DEFAULT_PROJECT_NAME) + + response = self.client.get("/auth/users/projects/leader/") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 4) + self.assertTrue( + all(item["name"] == DEFAULT_PROJECT_NAME for item in response.data["results"]) + ) + + def test_update_api_defaults_cleared_name_and_allows_renaming(self): + project = Project.objects.create(leader=self.user, name="Именованный проект") + for name in (None, "", " \t\n", "Новое название"): + with self.subTest(name=name): + response = self.client.put( + f"/projects/{project.pk}/", + {"name": name, "draft": True}, + format="json", + ) + self.assertEqual(response.status_code, 200) + expected_name = name if name == "Новое название" else DEFAULT_PROJECT_NAME + self.assertEqual(response.data["name"], expected_name) + project.refresh_from_db() + self.assertEqual(project.name, expected_name) + + def test_normalization_does_not_bypass_other_publication_validation(self): + project = Project.objects.create(leader=self.user, name="Название") + response = self.client.put( + f"/projects/{project.pk}/", + {"name": "", "description": "", "draft": False}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("description", response.data) + + def test_published_default_name_is_searchable(self): + project = Project.objects.create(leader=self.user) + response = self.client.put( + f"/projects/{project.pk}/", + {"name": "", "description": "Описание проекта", "draft": False}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], DEFAULT_PROJECT_NAME) + search = self.client.get("/projects/", {"name__contains": "без названия"}) + self.assertEqual(search.status_code, 200) + self.assertEqual([item["id"] for item in search.data["results"]], [project.pk]) + + def test_partial_model_save_persists_default_for_legacy_blank_name(self): + project = Project.objects.create(leader=self.user, name="Название") + Project.objects.filter(pk=project.pk).update(name=None) + project.refresh_from_db() + project.description = "Обновлённое описание" + project.save(update_fields=["description"]) + project.refresh_from_db() + self.assertEqual(project.name, DEFAULT_PROJECT_NAME) + self.assertEqual(project.description, "Обновлённое описание") + + def test_model_preserves_nonempty_name(self): + project = Project.objects.create(leader=self.user, name=" Мой проект ") + project.refresh_from_db() + self.assertEqual(project.name, " Мой проект ") + + def test_migration_fills_existing_blanks_without_signals_or_timestamp_changes(self): + projects = [] + for name in (None, "", " \t\r\n", "\u00a0", "\u2003", " Название "): + project = Project.objects.create(leader=self.user, name="До миграции") + Project.objects.filter(pk=project.pk).update(name=name) + projects.append((project, name, project.datetime_updated)) + + migration = import_module("projects.migrations.0034_default_project_name") + with patch("projects.models.Project.save") as save: + migration.fill_unnamed_projects(apps, SimpleNamespace(connection=connection)) + # Reapplying the cleanup must leave names and timestamps unchanged. + migration.fill_unnamed_projects(apps, SimpleNamespace(connection=connection)) + save.assert_not_called() + + for project, name, updated_at in projects: + with self.subTest(name=name): + project.refresh_from_db() + self.assertEqual( + project.name, + name if name and name.strip() else DEFAULT_PROJECT_NAME, + ) + self.assertEqual(project.datetime_updated, updated_at) diff --git a/projects/tests/test_project_workspace_api.py b/projects/tests/test_project_workspace_api.py index fa9e3c0b..03cb5ed7 100644 --- a/projects/tests/test_project_workspace_api.py +++ b/projects/tests/test_project_workspace_api.py @@ -229,6 +229,8 @@ def test_draft_requires_main_fields_before_publication(self): project.industry = None project.cover_image_address = None project.save() + # Simulate legacy data: save() now assigns a name to unnamed projects. + Project.objects.filter(pk=project.pk).update(name=None) self.authenticate(self.leader) response = self.client.patch( diff --git a/projects/validators.py b/projects/validators.py index 98dd75e6..5de3bba8 100644 --- a/projects/validators.py +++ b/projects/validators.py @@ -1,8 +1,13 @@ from django.core.validators import RegexValidator from rest_framework.serializers import ValidationError +from projects.names import normalize_project_name + def validate_project(data): + if "name" in data: + data["name"] = normalize_project_name(data["name"]) + if not data.get("draft"): error = {} allowed_blank = {"image_address"} From 019ab38eab5d1c81e996b32f9e231f7040673fe6 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 4 Oct 2026 18:59:39 +0300 Subject: [PATCH 2/2] fix(projects): backfill whitespace names independently of database collation --- .../migrations/0034_default_project_name.py | 8 +++++++- projects/tests/test_project_default_name.py | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/projects/migrations/0034_default_project_name.py b/projects/migrations/0034_default_project_name.py index 891878ac..01e94b8c 100644 --- a/projects/migrations/0034_default_project_name.py +++ b/projects/migrations/0034_default_project_name.py @@ -1,10 +1,16 @@ from django.db import migrations, models +# Match Python str.strip() whitespace independently of database collation. +BLANK_NAME_PATTERN = ( + "^[\t\n\v\f\r\x1c-\x1f \x85\u00a0\u1680" + "\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*$" +) + def fill_unnamed_projects(apps, schema_editor): Project = apps.get_model("projects", "Project") Project.objects.using(schema_editor.connection.alias).filter( - models.Q(name__isnull=True) | models.Q(name__regex=r"^\s*$") + models.Q(name__isnull=True) | models.Q(name__regex=BLANK_NAME_PATTERN) ).update(name="Проект без названия") diff --git a/projects/tests/test_project_default_name.py b/projects/tests/test_project_default_name.py index 02271443..b0dd4cfe 100644 --- a/projects/tests/test_project_default_name.py +++ b/projects/tests/test_project_default_name.py @@ -101,8 +101,23 @@ def test_model_preserves_nonempty_name(self): self.assertEqual(project.name, " Мой проект ") def test_migration_fills_existing_blanks_without_signals_or_timestamp_changes(self): + if connection.vendor == "postgresql": + # C collation does not classify NBSP as whitespace; migration must still fill it. + with connection.cursor() as cursor: + cursor.execute( + 'ALTER TABLE projects_project ALTER COLUMN name TYPE varchar(256) COLLATE "C"' + ) + projects = [] - for name in (None, "", " \t\r\n", "\u00a0", "\u2003", " Название "): + for name in ( + None, + "", + " \t\r\n", + "\u00a0", + "\u2003", + "\x1c\x85\u2007\u202f", + " Название ", + ): project = Project.objects.create(leader=self.user, name="До миграции") Project.objects.filter(pk=project.pk).update(name=name) projects.append((project, name, project.datetime_updated))