Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions projects/migrations/0034_default_project_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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=BLANK_NAME_PATTERN)
).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),
]
13 changes: 11 additions & 2 deletions projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions projects/names.py
Original file line number Diff line number Diff line change
@@ -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
139 changes: 139 additions & 0 deletions projects/tests/test_project_default_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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):
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",
"\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))

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)
5 changes: 5 additions & 0 deletions projects/validators.py
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
Loading