🔧 fix(repo): переводы строк в .py — только LF

21 файл доехал до репозитория с `\r\r\n` внутри: строка кончается двумя
возвратами каретки и переводом строки. Python такое читает, поэтому оно и
дожило, но линуксовые инструменты спотыкаются — ruff принимает одиночный CR за
перевод строки и пишет его в вывод, портя файл дальше.

Содержимое не менялось: `git diff -w` по этим файлам пуст, различаются только
переводы строк. Правило `*.py text eol=lf` в .gitattributes закрывает повтор —
на Windows такие файлы теперь и выкладываются с LF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 07:00:53 +03:00
1 parent 87a4359b04
commit bd40ff38fe
22 files changed
+753 -746

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ chatballs text eol=lf
# Dockerfile и манифесты стека читают Linux-инструменты: CR в них ломает
# RUN-строки и heredoc'и ровно так же, как шебанг.
# Python читают и переписывают линуксовые инструменты (ruff в контейнере, CI).
# На CRLF-чекауте ruff принимал за перевод строки одиночный CR и писал его в
# вывод — файлы оставались рабочими, но с ␍
внутри; 21 такой файл уже
# доехал до репозитория.
*.py text eol=lf
Dockerfile text eol=lf
Dockerfile.* text eol=lf
*.Dockerfile text eol=lf
+32 -32
View File
@@ -1,32 +1,32 @@
from django.conf import settings␍
from django.db.models import Sum␍
from django.utils import timezone␍
␍
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus␍
␍
␍
class LimitExceeded(Exception):␍
pass␍
␍
␍
def _day_start():␍
now = timezone.localtime()␍
return now.replace(hour=0, minute=0, second=0, microsecond=0)␍
␍
␍
def daily_cost_micros(channel=None) -> int:␍
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)␍
if channel is not None:␍
queryset = queryset.filter(channel=channel)␍
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0␍
␍
␍
def assert_within_limits(channel, agent) -> None:␍
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS␍
if global_limit and daily_cost_micros() >= global_limit:␍
raise LimitExceeded("Global daily AI cost limit reached")␍
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается␍
# в micro-USD. 1 цент = 10 000 micro-USD.␍
channel_limit = (agent.limits or {}).get("dailyCostUsd")␍
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:␍
raise LimitExceeded("Channel daily AI cost limit reached")␍
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
class LimitExceeded(Exception):
pass
def _day_start():
now = timezone.localtime()
return now.replace(hour=0, minute=0, second=0, microsecond=0)
def daily_cost_micros(channel=None) -> int:
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
if channel is not None:
queryset = queryset.filter(channel=channel)
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
def assert_within_limits(channel, agent) -> None:
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
if global_limit and daily_cost_micros() >= global_limit:
raise LimitExceeded("Global daily AI cost limit reached")
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
# в micro-USD. 1 цент = 10 000 micro-USD.
channel_limit = (agent.limits or {}).get("dailyCostUsd")
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
raise LimitExceeded("Channel daily AI cost limit reached")
+17 -17
View File
@@ -1,17 +1,17 @@
from django.conf import settings␍
␍
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.␍
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).␍
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.␍
DEFAULT_PRICING = {␍
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},␍
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},␍
}␍
␍
␍
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:␍
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}␍
price = table.get(model)␍
if not price:␍
return 0␍
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])␍
from django.conf import settings
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
DEFAULT_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
}
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
price = table.get(model)
if not price:
return 0
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
+8 -8
View File
@@ -1,8 +1,8 @@
from django.apps import AppConfig␍
␍
␍
class ChannelsConfig(AppConfig):␍
default_auto_field = "django.db.models.BigAutoField"␍
label = "channels"␍
name = "chatballs.channels"␍
verbose_name = "Processing channels (AI context)"␍
from django.apps import AppConfig
class ChannelsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "channels"
name = "chatballs.channels"
verbose_name = "Processing channels (AI context)"
+21 -21
View File
@@ -1,21 +1,21 @@
from django.contrib import admin␍
␍
from chatballs.conversations.models import Contact, Conversation, Message␍
␍
␍
@admin.register(Conversation)␍
class ConversationAdmin(admin.ModelAdmin):␍
list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")␍
list_filter = ("lifecycle", "control_mode")␍
␍
␍
@admin.register(Contact)␍
class ContactAdmin(admin.ModelAdmin):␍
list_display = ("id", "name", "organization", "created_at")␍
search_fields = ("name",)␍
␍
␍
@admin.register(Message)␍
class MessageAdmin(admin.ModelAdmin):␍
list_display = ("id", "conversation", "author_type", "created_at")␍
list_filter = ("author_type",)␍
from django.contrib import admin
from chatballs.conversations.models import Contact, Conversation, Message
@admin.register(Conversation)
class ConversationAdmin(admin.ModelAdmin):
list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")
list_filter = ("lifecycle", "control_mode")
@admin.register(Contact)
class ContactAdmin(admin.ModelAdmin):
list_display = ("id", "name", "organization", "created_at")
search_fields = ("name",)
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ("id", "conversation", "author_type", "created_at")
list_filter = ("author_type",)
@@ -1,44 +1,44 @@
# Generated by Django 5.2.15 on 2026-09-04 20:40␍
␍
import chatballs.conversations.models␍
from django.db import migrations, models␍
␍
␍
class Migration(migrations.Migration):␍
␍
dependencies = [␍
('conversations', '0011_conversation_archived_at_conversation_note_and_more'),␍
]␍
␍
operations = [␍
migrations.AddField(␍
model_name='message',␍
name='audio',␍
field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='audio_content_type',␍
field=models.CharField(blank=True, max_length=64),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='duration_seconds',␍
field=models.PositiveIntegerField(default=0),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='transcript',␍
field=models.TextField(blank=True),␍
),␍
migrations.AddField(␍
model_name='message',␍
name='transcript_status',␍
field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),␍
),␍
migrations.AlterField(␍
model_name='message',␍
name='kind',␍
field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),␍
),␍
]␍
# Generated by Django 5.2.15 on 2026-09-04 20:40
import chatballs.conversations.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('conversations', '0011_conversation_archived_at_conversation_note_and_more'),
]
operations = [
migrations.AddField(
model_name='message',
name='audio',
field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path),
),
migrations.AddField(
model_name='message',
name='audio_content_type',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='message',
name='duration_seconds',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='message',
name='transcript',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='message',
name='transcript_status',
field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),
),
migrations.AlterField(
model_name='message',
name='kind',
field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),
),
]
+38 -38
View File
@@ -1,38 +1,38 @@
import logging␍
␍
from chatballs.conversations import transports␍
from chatballs.conversations.ingest import ingest_inbound␍
from chatballs.integrations.models import Integration␍
␍
logger = logging.getLogger(__name__)␍
␍
␍
def poll_all_messengers(context) -> int:␍
"""Poll every messenger connection bound to a channel; ingest inbound. Returns count."""␍
# Сервисные боты уведомлений поллятся отдельно (notifications.binding).␍
# Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки␍
# без ключа purpose (NULL в SQL), т.е. все клиентские боты.␍
integrations = [␍
integration␍
for integration in Integration.objects.filter(␍
organization=context.organization,␍
provider__in=transports.SUPPORTED_PROVIDERS,␍
is_active=True,␍
channel__isnull=False,␍
channel__is_active=True,␍
).exclude(secret="")␍
if integration.config.get("purpose") != "notifications"␍
]␍
total = 0␍
for integration in integrations:␍
messages, new_marker = transports.poll(integration)␍
for inbound in messages:␍
try:␍
ingest_inbound(integration, inbound)␍
total += 1␍
except Exception: # pragma: no cover␍
logger.exception("Ingest failed for integration %s", integration.id)␍
if new_marker and new_marker != integration.poll_marker:␍
integration.poll_marker = new_marker␍
integration.save(update_fields=["poll_marker", "updated_at"])␍
return total␍
import logging
from chatballs.conversations import transports
from chatballs.conversations.ingest import ingest_inbound
from chatballs.integrations.models import Integration
logger = logging.getLogger(__name__)
def poll_all_messengers(context) -> int:
"""Poll every messenger connection bound to a channel; ingest inbound. Returns count."""
# Сервисные боты уведомлений поллятся отдельно (notifications.binding).
# Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки
# без ключа purpose (NULL в SQL), т.е. все клиентские боты.
integrations = [
integration
for integration in Integration.objects.filter(
organization=context.organization,
provider__in=transports.SUPPORTED_PROVIDERS,
is_active=True,
channel__isnull=False,
channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
]
total = 0
for integration in integrations:
messages, new_marker = transports.poll(integration)
for inbound in messages:
try:
ingest_inbound(integration, inbound)
total += 1
except Exception: # pragma: no cover
logger.exception("Ingest failed for integration %s", integration.id)
if new_marker and new_marker != integration.poll_marker:
integration.poll_marker = new_marker
integration.save(update_fields=["poll_marker", "updated_at"])
return total
@@ -1,43 +1,43 @@
from rest_framework.request import Request␍
from rest_framework.views import APIView␍
␍
from chatballs.api.permissions import HasCapability␍
from chatballs.conversations.models import Conversation␍
from chatballs.conversations.selectors import conversation_for_context␍
from chatballs.identity.audit import record_audit_event␍
from chatballs.identity.policy import require_capability␍
␍
␍
class ConversationViewBase(APIView):␍
permission_classes = [HasCapability]␍
required_capability = "conversations.view"␍
␍
def _org(self, request: Request):␍
return request.tenant_context.organization␍
␍
def _conversation(␍
self,␍
request: Request,␍
conversation_id: int,␍
capability: str = "conversations.view",␍
) -> Conversation:␍
conversation = conversation_for_context(␍
context=request.tenant_context, conversation_id=conversation_id␍
)␍
if not require_capability(␍
request.tenant_context.membership, capability, conversation␍
):␍
raise Conversation.DoesNotExist␍
return conversation␍
␍
def _audit(␍
self, request: Request, action: str, conversation: Conversation␍
) -> None:␍
record_audit_event(␍
action=f"conversations.{action}",␍
actor=request.user,␍
organization=self._org(request),␍
object_type="Conversation",␍
object_id=str(conversation.id),␍
request=request,␍
)␍
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.conversations.models import Conversation
from chatballs.conversations.selectors import conversation_for_context
from chatballs.identity.audit import record_audit_event
from chatballs.identity.policy import require_capability
class ConversationViewBase(APIView):
permission_classes = [HasCapability]
required_capability = "conversations.view"
def _org(self, request: Request):
return request.tenant_context.organization
def _conversation(
self,
request: Request,
conversation_id: int,
capability: str = "conversations.view",
) -> Conversation:
conversation = conversation_for_context(
context=request.tenant_context, conversation_id=conversation_id
)
if not require_capability(
request.tenant_context.membership, capability, conversation
):
raise Conversation.DoesNotExist
return conversation
def _audit(
self, request: Request, action: str, conversation: Conversation
) -> None:
record_audit_event(
action=f"conversations.{action}",
actor=request.user,
organization=self._org(request),
object_type="Conversation",
object_id=str(conversation.id),
request=request,
)
@@ -1,82 +1,82 @@
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.␍
#␍
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям␍
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в␍
# момент выката.␍
#␍
# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на␍
# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не␍
# трогаем назначения: каждое действующее назначение профиля продолжает работать␍
# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так␍
# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.␍
from django.db import migrations, models␍
␍
AI_VIEW = "ai.view"␍
AI_MANAGE = "ai.manage"␍
CHANNELS_VIEW = "channels.view"␍
CHANNELS_MANAGE = "channels.manage"␍
␍
␍
def _grant(apps, *, source: str, target: str) -> None:␍
AccessProfile = apps.get_model("identity", "AccessProfile")␍
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")␍
␍
already_granted = set(␍
AccessProfileCapability.objects.filter(capability_code=target).values_list(␍
"access_profile_id", flat=True␍
)␍
)␍
# organization_id берётся у профиля, а не выводится: триггер␍
# chatballs.enforce_tenant_fk требует совпадения с владельцем профиля.␍
profiles = (␍
AccessProfile.objects.filter(capability_links__capability_code=source)␍
.values_list("id", "organization_id")␍
.distinct()␍
)␍
AccessProfileCapability.objects.bulk_create(␍
[␍
AccessProfileCapability(␍
access_profile_id=profile_id,␍
organization_id=organization_id,␍
capability_code=target,␍
)␍
for profile_id, organization_id in profiles␍
if profile_id not in already_granted␍
]␍
)␍
␍
␍
def grant_channel_capabilities(apps, schema_editor):␍
_grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)␍
_grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)␍
␍
␍
def revoke_channel_capabilities(apps, schema_editor):␍
# В отличие от 0010 откат обязан удалить выданные строки: следом␍
# восстанавливается прежний check-constraint реестра, и строки с кодом вне␍
# списка сделали бы обратную миграцию невыполнимой.␍
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")␍
AccessProfileCapability.objects.filter(␍
capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)␍
).delete()␍
␍
␍
class Migration(migrations.Migration):␍
␍
dependencies = [␍
('identity', '0015_organization_status'),␍
]␍
␍
operations = [␍
migrations.RemoveConstraint(␍
model_name='accessprofilecapability',␍
name='access_profile_capability_registry',␍
),␍
migrations.AddConstraint(␍
model_name='accessprofilecapability',␍
constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),␍
),␍
# Порядок важен: при откате Django исполняет операции в обратном порядке,␍
# поэтому строки удаляются до восстановления старого constraint.␍
migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),␍
]␍
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
#
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
# момент выката.
#
# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на
# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не
# трогаем назначения: каждое действующее назначение профиля продолжает работать
# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так
# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.
from django.db import migrations, models
AI_VIEW = "ai.view"
AI_MANAGE = "ai.manage"
CHANNELS_VIEW = "channels.view"
CHANNELS_MANAGE = "channels.manage"
def _grant(apps, *, source: str, target: str) -> None:
AccessProfile = apps.get_model("identity", "AccessProfile")
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
already_granted = set(
AccessProfileCapability.objects.filter(capability_code=target).values_list(
"access_profile_id", flat=True
)
)
# organization_id берётся у профиля, а не выводится: триггер
# chatballs.enforce_tenant_fk требует совпадения с владельцем профиля.
profiles = (
AccessProfile.objects.filter(capability_links__capability_code=source)
.values_list("id", "organization_id")
.distinct()
)
AccessProfileCapability.objects.bulk_create(
[
AccessProfileCapability(
access_profile_id=profile_id,
organization_id=organization_id,
capability_code=target,
)
for profile_id, organization_id in profiles
if profile_id not in already_granted
]
)
def grant_channel_capabilities(apps, schema_editor):
_grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)
_grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)
def revoke_channel_capabilities(apps, schema_editor):
# В отличие от 0010 откат обязан удалить выданные строки: следом
# восстанавливается прежний check-constraint реестра, и строки с кодом вне
# списка сделали бы обратную миграцию невыполнимой.
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
AccessProfileCapability.objects.filter(
capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)
).delete()
class Migration(migrations.Migration):
dependencies = [
('identity', '0015_organization_status'),
]
operations = [
migrations.RemoveConstraint(
model_name='accessprofilecapability',
name='access_profile_capability_registry',
),
migrations.AddConstraint(
model_name='accessprofilecapability',
constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),
),
# Порядок важен: при откате Django исполняет операции в обратном порядке,
# поэтому строки удаляются до восстановления старого constraint.
migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),
]
@@ -1,173 +1,173 @@
# Generated by Django 5.2.15 on 2026-09-03 22:23␍
␍
import django.db.models.deletion␍
import django.db.models.functions.text␍
from django.db import migrations, models␍
␍
# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и␍
# грантов, которые исходно раздавала tenancy/0003 (она при откате не␍
# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут␍
# читать identity_department и migration-тесты падают на старых состояниях.␍
# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при␍
# откате (операции разворачиваются в обратном порядке), когда таблицы уже␍
# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях␍
# тестами не используется, поэтому политики не восстанавливаем.␍
_RESTORE_GRANTS_SQL = "\n".join(␍
f"""␍
ALTER TABLE {table} OWNER TO chatballs_schema;␍
GRANT ALL ON {table} TO chatballs_schema;␍
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;␍
"""␍
for table in (␍
"identity_department",␍
"identity_accessprofile",␍
"identity_accessprofilecapability",␍
"identity_employeeaccessassignment",␍
)␍
)␍
␍
␍
class Migration(migrations.Migration):␍
␍
dependencies = [␍
('ai', '0014_remove_knowledgedepartment_department_and_more'),␍
('channels', '0006_remove_channel_department'),␍
('identity', '0019_drop_sales_capabilities'),␍
('notifications', '0008_remove_notification_department'),␍
('support_portals', '0008_remove_supportportal_department'),␍
]␍
␍
operations = [␍
migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),␍
migrations.CreateModel(␍
name='EmployeeGroup',␍
fields=[␍
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),␍
('name', models.CharField(max_length=120)),␍
('created_at', models.DateTimeField(auto_now_add=True)),␍
],␍
options={␍
'ordering': ['name'],␍
},␍
),␍
migrations.CreateModel(␍
name='EmployeeGroupMember',␍
fields=[␍
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),␍
('created_at', models.DateTimeField(auto_now_add=True)),␍
],␍
),␍
# Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе␍
# state хранит constraint на несуществующее поле и обратная миграция␍
# (reverse DeleteModel в migration-тестах) падает при create_model.␍
migrations.RemoveConstraint(␍
model_name='accessprofile',␍
name='uniq_access_profile_org_name_ci',␍
),␍
migrations.RemoveConstraint(␍
model_name='accessprofilecapability',␍
name='uniq_access_profile_capability',␍
),␍
migrations.RemoveConstraint(␍
model_name='department',␍
name='uniq_department_org_code',␍
),␍
migrations.RemoveConstraint(␍
model_name='employeeaccessassignment',␍
name='access_assignment_scope_department',␍
),␍
migrations.RemoveConstraint(␍
model_name='employeeaccessassignment',␍
name='uniq_active_org_access_assignment',␍
),␍
migrations.RemoveConstraint(␍
model_name='employeeaccessassignment',␍
name='uniq_active_dept_access_assignment',␍
),␍
migrations.RemoveField(␍
model_name='accessprofile',␍
name='organization',␍
),␍
migrations.RemoveField(␍
model_name='accessprofilecapability',␍
name='access_profile',␍
),␍
migrations.RemoveField(␍
model_name='employeeaccessassignment',␍
name='access_profile',␍
),␍
migrations.RemoveField(␍
model_name='accessprofilecapability',␍
name='organization',␍
),␍
migrations.RemoveField(␍
model_name='department',␍
name='organization',␍
),␍
# Сначала снимаем constraint, зависящий от primary_department: дроп␍
# колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.␍
migrations.RemoveConstraint(␍
model_name='organizationmembership',␍
name='owner_is_company_level',␍
),␍
migrations.RemoveField(␍
model_name='organizationmembership',␍
name='primary_department',␍
),␍
migrations.RemoveField(␍
model_name='employeeaccessassignment',␍
name='department',␍
),␍
migrations.RemoveField(␍
model_name='employeeaccessassignment',␍
name='assigned_by',␍
),␍
migrations.RemoveField(␍
model_name='employeeaccessassignment',␍
name='employee',␍
),␍
migrations.RemoveField(␍
model_name='employeeaccessassignment',␍
name='organization',␍
),␍
migrations.AddField(␍
model_name='employeegroup',␍
name='organization',␍
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),␍
),␍
migrations.AddField(␍
model_name='employeegroupmember',␍
name='employee',␍
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),␍
),␍
migrations.AddField(␍
model_name='employeegroupmember',␍
name='group',␍
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),␍
),␍
migrations.AddField(␍
model_name='employeegroupmember',␍
name='organization',␍
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),␍
),␍
migrations.DeleteModel(␍
name='AccessProfile',␍
),␍
migrations.DeleteModel(␍
name='AccessProfileCapability',␍
),␍
migrations.DeleteModel(␍
name='Department',␍
),␍
migrations.DeleteModel(␍
name='EmployeeAccessAssignment',␍
),␍
migrations.AddConstraint(␍
model_name='employeegroup',␍
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),␍
),␍
migrations.AddConstraint(␍
model_name='employeegroupmember',␍
constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),␍
),␍
]␍
# Generated by Django 5.2.15 on 2026-09-03 22:23
import django.db.models.deletion
import django.db.models.functions.text
from django.db import migrations, models
# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и
# грантов, которые исходно раздавала tenancy/0003 (она при откате не
# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут
# читать identity_department и migration-тесты падают на старых состояниях.
# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при
# откате (операции разворачиваются в обратном порядке), когда таблицы уже
# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях
# тестами не используется, поэтому политики не восстанавливаем.
_RESTORE_GRANTS_SQL = "\n".join(
f"""
ALTER TABLE {table} OWNER TO chatballs_schema;
GRANT ALL ON {table} TO chatballs_schema;
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
"""
for table in (
"identity_department",
"identity_accessprofile",
"identity_accessprofilecapability",
"identity_employeeaccessassignment",
)
)
class Migration(migrations.Migration):
dependencies = [
('ai', '0014_remove_knowledgedepartment_department_and_more'),
('channels', '0006_remove_channel_department'),
('identity', '0019_drop_sales_capabilities'),
('notifications', '0008_remove_notification_department'),
('support_portals', '0008_remove_supportportal_department'),
]
operations = [
migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),
migrations.CreateModel(
name='EmployeeGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='EmployeeGroupMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
# Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе
# state хранит constraint на несуществующее поле и обратная миграция
# (reverse DeleteModel в migration-тестах) падает при create_model.
migrations.RemoveConstraint(
model_name='accessprofile',
name='uniq_access_profile_org_name_ci',
),
migrations.RemoveConstraint(
model_name='accessprofilecapability',
name='uniq_access_profile_capability',
),
migrations.RemoveConstraint(
model_name='department',
name='uniq_department_org_code',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='access_assignment_scope_department',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='uniq_active_org_access_assignment',
),
migrations.RemoveConstraint(
model_name='employeeaccessassignment',
name='uniq_active_dept_access_assignment',
),
migrations.RemoveField(
model_name='accessprofile',
name='organization',
),
migrations.RemoveField(
model_name='accessprofilecapability',
name='access_profile',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='access_profile',
),
migrations.RemoveField(
model_name='accessprofilecapability',
name='organization',
),
migrations.RemoveField(
model_name='department',
name='organization',
),
# Сначала снимаем constraint, зависящий от primary_department: дроп
# колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.
migrations.RemoveConstraint(
model_name='organizationmembership',
name='owner_is_company_level',
),
migrations.RemoveField(
model_name='organizationmembership',
name='primary_department',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='department',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='assigned_by',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='employee',
),
migrations.RemoveField(
model_name='employeeaccessassignment',
name='organization',
),
migrations.AddField(
model_name='employeegroup',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),
),
migrations.AddField(
model_name='employeegroupmember',
name='employee',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),
),
migrations.AddField(
model_name='employeegroupmember',
name='group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),
),
migrations.AddField(
model_name='employeegroupmember',
name='organization',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
),
migrations.DeleteModel(
name='AccessProfile',
),
migrations.DeleteModel(
name='AccessProfileCapability',
),
migrations.DeleteModel(
name='Department',
),
migrations.DeleteModel(
name='EmployeeAccessAssignment',
),
migrations.AddConstraint(
model_name='employeegroup',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),
),
migrations.AddConstraint(
model_name='employeegroupmember',
constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),
),
]
+11 -11
View File
@@ -1,11 +1,11 @@
from django.contrib import admin␍
␍
from chatballs.integrations.models import Integration␍
␍
␍
@admin.register(Integration)␍
class IntegrationAdmin(admin.ModelAdmin):␍
list_display = ("name", "provider", "kind", "status", "last_checked_at")␍
list_filter = ("provider", "kind", "status")␍
search_fields = ("name",)␍
readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")␍
from django.contrib import admin
from chatballs.integrations.models import Integration
@admin.register(Integration)
class IntegrationAdmin(admin.ModelAdmin):
list_display = ("name", "provider", "kind", "status", "last_checked_at")
list_filter = ("provider", "kind", "status")
search_fields = ("name",)
readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")
+8 -8
View File
@@ -1,8 +1,8 @@
from django.apps import AppConfig␍
␍
␍
class IntegrationsConfig(AppConfig):␍
default_auto_field = "django.db.models.BigAutoField"␍
label = "integrations"␍
name = "chatballs.integrations"␍
verbose_name = "Integrations: providers and connections"␍
from django.apps import AppConfig
class IntegrationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "integrations"
name = "chatballs.integrations"
verbose_name = "Integrations: providers and connections"
@@ -1,38 +1,38 @@
# Generated by Django 5.2.15 on 2026-06-27 21:12␍
␍
import django.db.models.deletion␍
import chatballs.identity.crypto␍
from django.db import migrations, models␍
␍
␍
class Migration(migrations.Migration):␍
␍
initial = True␍
␍
dependencies = [␍
('identity', '0006_alter_employeeprofile_totp_secret'),␍
]␍
␍
operations = [␍
migrations.CreateModel(␍
name='Integration',␍
fields=[␍
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),␍
('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),␍
('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),␍
('name', models.CharField(max_length=255)),␍
('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),␍
('config', models.JSONField(blank=True, default=dict)),␍
('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),␍
('last_checked_at', models.DateTimeField(blank=True, null=True)),␍
('last_error', models.TextField(blank=True)),␍
('created_at', models.DateTimeField(auto_now_add=True)),␍
('updated_at', models.DateTimeField(auto_now=True)),␍
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),␍
],␍
options={␍
'ordering': ['provider', 'name'],␍
'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],␍
},␍
),␍
]␍
# Generated by Django 5.2.15 on 2026-06-27 21:12
import django.db.models.deletion
import chatballs.identity.crypto
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('identity', '0006_alter_employeeprofile_totp_secret'),
]
operations = [
migrations.CreateModel(
name='Integration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),
('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),
('name', models.CharField(max_length=255)),
('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),
('config', models.JSONField(blank=True, default=dict)),
('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),
('last_checked_at', models.DateTimeField(blank=True, null=True)),
('last_error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),
],
options={
'ordering': ['provider', 'name'],
'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],
},
),
]
+9 -9
View File
@@ -1,9 +1,9 @@
from django.urls import path␍
␍
from chatballs.integrations import views␍
␍
urlpatterns = [␍
path("", views.IntegrationListView.as_view(), name="integration-list"),␍
path("<int:integration_id>/", views.IntegrationDetailView.as_view(), name="integration-detail"),␍
path("<int:integration_id>/test/", views.IntegrationTestView.as_view(), name="integration-test"),␍
]␍
from django.urls import path
from chatballs.integrations import views
urlpatterns = [
path("", views.IntegrationListView.as_view(), name="integration-list"),
path("<int:integration_id>/", views.IntegrationDetailView.as_view(), name="integration-detail"),
path("<int:integration_id>/test/", views.IntegrationTestView.as_view(), name="integration-test"),
]
+15 -15
View File
@@ -1,15 +1,15 @@
from django.contrib import admin␍
␍
from chatballs.notifications.models import Notification, NotificationRead␍
␍
␍
@admin.register(Notification)␍
class NotificationAdmin(admin.ModelAdmin):␍
list_display = ("type", "audience", "level", "title", "organization", "created_at")␍
list_filter = ("type", "audience", "level")␍
search_fields = ("title", "body", "dedup_key")␍
␍
␍
@admin.register(NotificationRead)␍
class NotificationReadAdmin(admin.ModelAdmin):␍
list_display = ("notification", "user", "read_at")␍
from django.contrib import admin
from chatballs.notifications.models import Notification, NotificationRead
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ("type", "audience", "level", "title", "organization", "created_at")
list_filter = ("type", "audience", "level")
search_fields = ("title", "body", "dedup_key")
@admin.register(NotificationRead)
class NotificationReadAdmin(admin.ModelAdmin):
list_display = ("notification", "user", "read_at")
+11 -11
View File
@@ -1,11 +1,11 @@
from django.apps import AppConfig␍
␍
␍
class NotificationsConfig(AppConfig):␍
default_auto_field = "django.db.models.BigAutoField"␍
label = "notifications"␍
name = "chatballs.notifications"␍
verbose_name = "Notifications"␍
␍
def ready(self) -> None:␍
from chatballs.notifications import event_handlers # noqa: F401 (register outbox handlers)␍
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "notifications"
name = "chatballs.notifications"
verbose_name = "Notifications"
def ready(self) -> None:
from chatballs.notifications import event_handlers # noqa: F401 (register outbox handlers)
@@ -1,15 +1,15 @@
from chatballs.notifications.models import Notification␍
␍
␍
def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]:␍
return {␍
"id": notification.id,␍
"type": notification.type,␍
"level": notification.level,␍
"title": notification.title,␍
"body": notification.body,␍
"targetRoute": notification.target_route,␍
"targetId": notification.target_id,␍
"createdAt": notification.created_at.isoformat(),␍
"unread": unread,␍
}␍
from chatballs.notifications.models import Notification
def notification_payload(notification: Notification, *, unread: bool) -> dict[str, object]:
return {
"id": notification.id,
"type": notification.type,
"level": notification.level,
"title": notification.title,
"body": notification.body,
"targetRoute": notification.target_route,
"targetId": notification.target_id,
"createdAt": notification.created_at.isoformat(),
"unread": unread,
}
+10 -10
View File
@@ -1,10 +1,10 @@
from django.urls import path␍
␍
from chatballs.notifications import views␍
␍
urlpatterns = [␍
path("", views.NotificationListView.as_view(), name="notification-list"),␍
path("read/", views.NotificationReadView.as_view(), name="notification-read"),␍
path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"),␍
path("messenger-bindings/<int:integration_id>/", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"),␍
]␍
from django.urls import path
from chatballs.notifications import views
urlpatterns = [
path("", views.NotificationListView.as_view(), name="notification-list"),
path("read/", views.NotificationReadView.as_view(), name="notification-read"),
path("messenger-bindings/", views.MessengerBindingListView.as_view(), name="messenger-binding-list"),
path("messenger-bindings/<int:integration_id>/", views.MessengerBindingDetailView.as_view(), name="messenger-binding-detail"),
]
@@ -1,27 +1,27 @@
from django.db import migrations␍
␍
# Django ORM inserts use INSERT ... RETURNING id; PostgreSQL applies SELECT␍
# policies to rows returned by RETURNING, so the app role needs SELECT␍
# visibility of the platform-scope rows it is allowed to insert␍
# (chatballs_app_platform_audit_insert / chatballs_app_platform_outbox_insert).␍
# Without these policies a login-failed audit write fails with␍
# "new row violates row-level security policy".␍
CREATE_POLICIES = """␍
DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent;␍
CREATE POLICY chatballs_app_platform_audit_select ON identity_auditevent␍
FOR SELECT TO chatballs_runtime_app USING (organization_id IS NULL);␍
DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent;␍
CREATE POLICY chatballs_app_platform_outbox_select ON events_outboxevent␍
FOR SELECT TO chatballs_runtime_app␍
USING (ownership = 'PLATFORM' AND organization_id IS NULL);␍
"""␍
␍
DROP_POLICIES = """␍
DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent;␍
DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent;␍
"""␍
␍
␍
class Migration(migrations.Migration):␍
dependencies = [("tenancy", "0006_storage_reserved_bytes")]␍
operations = [migrations.RunSQL(CREATE_POLICIES, DROP_POLICIES)]␍
from django.db import migrations
# Django ORM inserts use INSERT ... RETURNING id; PostgreSQL applies SELECT
# policies to rows returned by RETURNING, so the app role needs SELECT
# visibility of the platform-scope rows it is allowed to insert
# (chatballs_app_platform_audit_insert / chatballs_app_platform_outbox_insert).
# Without these policies a login-failed audit write fails with
# "new row violates row-level security policy".
CREATE_POLICIES = """
DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent;
CREATE POLICY chatballs_app_platform_audit_select ON identity_auditevent
FOR SELECT TO chatballs_runtime_app USING (organization_id IS NULL);
DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent;
CREATE POLICY chatballs_app_platform_outbox_select ON events_outboxevent
FOR SELECT TO chatballs_runtime_app
USING (ownership = 'PLATFORM' AND organization_id IS NULL);
"""
DROP_POLICIES = """
DROP POLICY IF EXISTS chatballs_app_platform_audit_select ON identity_auditevent;
DROP POLICY IF EXISTS chatballs_app_platform_outbox_select ON events_outboxevent;
"""
class Migration(migrations.Migration):
dependencies = [("tenancy", "0006_storage_reserved_bytes")]
operations = [migrations.RunSQL(CREATE_POLICIES, DROP_POLICIES)]
@@ -1,43 +1,43 @@
"""Guard-функции C04 должны видеть строки поверх RLS (SECURITY DEFINER).␍
␍
Функции проверяют целостность связей между тенантами и обязаны читать␍
родительскую строку независимо от политик вызывающей роли. Как SECURITY␍
INVOKER их внутренний SELECT подчинялся RLS: из кросс-тенантного пути без␍
выставленного `chatballs.organization_id` (outbox-воркер claim'ит событие␍
любой организации) родитель не виден, parent_org = NULL, и проверка␍
`IS DISTINCT FROM` ложно срабатывала — легитимный UPDATE падал с␍
`cross-tenant relation`, что уводило воркер в краш-петлю.␍
␍
SECURITY DEFINER исполняет их от chatballs_schema (policy USING (true)),␍
поэтому сравнение идёт по фактическим данным. Проверка не ослабляется:␍
настоящее нарушение по-прежнему приводит к RAISE. search_path закреплён,␍
как того требует безопасность SECURITY DEFINER-функций.␍
"""␍
␍
from django.db import migrations␍
␍
␍
GUARD_FUNCTIONS = (␍
"chatballs.enforce_tenant_fk()",␍
"chatballs.enforce_tenant_user()",␍
"chatballs.enforce_tenant_pair()",␍
)␍
␍
␍
def set_security_definer(apps, schema_editor):␍
for function in GUARD_FUNCTIONS:␍
schema_editor.execute(␍
f"ALTER FUNCTION {function} SECURITY DEFINER SET search_path = public, pg_temp"␍
)␍
␍
␍
def set_security_invoker(apps, schema_editor):␍
for function in GUARD_FUNCTIONS:␍
schema_editor.execute(␍
f"ALTER FUNCTION {function} SECURITY INVOKER RESET search_path"␍
)␍
␍
␍
class Migration(migrations.Migration):␍
dependencies = [("tenancy", "0008_ai_knowledge_scope_guards")]␍
operations = [migrations.RunPython(set_security_definer, set_security_invoker)]␍
"""Guard-функции C04 должны видеть строки поверх RLS (SECURITY DEFINER).
Функции проверяют целостность связей между тенантами и обязаны читать
родительскую строку независимо от политик вызывающей роли. Как SECURITY
INVOKER их внутренний SELECT подчинялся RLS: из кросс-тенантного пути без
выставленного `chatballs.organization_id` (outbox-воркер claim'ит событие
любой организации) родитель не виден, parent_org = NULL, и проверка
`IS DISTINCT FROM` ложно срабатывала — легитимный UPDATE падал с
`cross-tenant relation`, что уводило воркер в краш-петлю.
SECURITY DEFINER исполняет их от chatballs_schema (policy USING (true)),
поэтому сравнение идёт по фактическим данным. Проверка не ослабляется:
настоящее нарушение по-прежнему приводит к RAISE. search_path закреплён,
как того требует безопасность SECURITY DEFINER-функций.
"""
from django.db import migrations
GUARD_FUNCTIONS = (
"chatballs.enforce_tenant_fk()",
"chatballs.enforce_tenant_user()",
"chatballs.enforce_tenant_pair()",
)
def set_security_definer(apps, schema_editor):
for function in GUARD_FUNCTIONS:
schema_editor.execute(
f"ALTER FUNCTION {function} SECURITY DEFINER SET search_path = public, pg_temp"
)
def set_security_invoker(apps, schema_editor):
for function in GUARD_FUNCTIONS:
schema_editor.execute(
f"ALTER FUNCTION {function} SECURITY INVOKER RESET search_path"
)
class Migration(migrations.Migration):
dependencies = [("tenancy", "0008_ai_knowledge_scope_guards")]
operations = [migrations.RunPython(set_security_definer, set_security_invoker)]
+8 -8
View File
@@ -1,8 +1,8 @@
from django.apps import AppConfig␍
␍
␍
class WebchatConfig(AppConfig):␍
default_auto_field = "django.db.models.BigAutoField"␍
label = "webchat"␍
name = "chatballs.webchat"␍
verbose_name = "Web chat widget"␍
from django.apps import AppConfig
class WebchatConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "webchat"
name = "chatballs.webchat"
verbose_name = "Web chat widget"
@@ -1,93 +1,93 @@
import os␍
␍
from django.core.exceptions import ImproperlyConfigured␍
␍
from chatballs_backend.settings_env import env_secret␍
␍
␍
def _credentials() -> tuple[dict[str, str], dict[str, str]]:␍
# Имена ролей — константы продукта, а не настройка установки: их создаёт␍
# deploy/postgres/init-runtime-roles.sh при первом старте. Переменные␍
# окружения остаются переопределением для нестандартных установок.␍
users = {␍
"app": os.environ.get("POSTGRES_APP_USER", "chatballs_app"),␍
"platform": os.environ.get("POSTGRES_PLATFORM_USER", "chatballs_platform"),␍
"migration": os.environ.get(␍
"POSTGRES_MIGRATION_USER", "chatballs_migration"␍
),␍
}␍
# Пароли ролей генерирует первый старт стека в том с секретами; человек их␍
# не вводит и не хранит. Переменные окружения остаются переопределением.␍
fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs")␍
passwords = {␍
"app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback),␍
"platform": env_secret(␍
"POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback␍
),␍
"migration": env_secret(␍
"POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback␍
),␍
}␍
return users, passwords␍
␍
␍
def _pool_options(*, testing: bool) -> dict | None:␍
# ASGI-серверы исполняют ORM в короткоживущих потоках sync_to_async;␍
# persistent-соединения (CONN_MAX_AGE > 0) в таких потоках осиротевают и␍
# исчерпывают max_connections Postgres. Вместо них — psycopg pool на процесс:␍
# соединения возвращаются в пул независимо от потока и ограничены сверху.␍
# CHATBALLS_DB_POOL_MAX=0 отключает пул (короткоживущие соединения на запрос).␍
if testing:␍
return None␍
max_size = int(os.environ.get("CHATBALLS_DB_POOL_MAX", "4"))␍
if max_size <= 0:␍
return None␍
return {␍
"min_size": int(os.environ.get("CHATBALLS_DB_POOL_MIN", "1")),␍
"max_size": max_size,␍
"timeout": float(os.environ.get("CHATBALLS_DB_POOL_TIMEOUT", "10")),␍
}␍
␍
␍
def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:␍
role = os.environ.get("CHATBALLS_DB_ROLE", "app").lower()␍
if role not in {"app", "platform", "migration"}:␍
raise ImproperlyConfigured("CHATBALLS_DB_ROLE must be app, platform or migration")␍
users, passwords = _credentials()␍
if not debug and not testing and len(set(users.values())) != 3:␍
raise ImproperlyConfigured(␍
"App, platform and migration database users must be distinct"␍
)␍
pool = _pool_options(testing=testing)␍
␍
def config(selected_role: str) -> dict:␍
return {␍
"ENGINE": "django.db.backends.postgresql",␍
"NAME": os.environ.get("POSTGRES_DB", "chatballs"),␍
"USER": users[selected_role],␍
"PASSWORD": passwords[selected_role],␍
"HOST": os.environ.get("POSTGRES_HOST", "postgres"),␍
"PORT": os.environ.get("POSTGRES_PORT", "5432"),␍
# Пул несовместим с persistent-соединениями: с ним CONN_MAX_AGE␍
# обязан быть 0, а без пула persistent-режим возвращать нельзя␍
# (см. _pool_options).␍
"CONN_MAX_AGE": 0,␍
"OPTIONS": {"pool": dict(pool)} if pool else {},␍
}␍
␍
databases = {␍
"default": config("migration" if testing else role),␍
"platform": config("platform"),␍
}␍
if testing:␍
# Тесты создают свою БД и подключаются владельцем кластера. Его пароль␍
# приходит оттуда же, откуда у остальных ролей: файл секрета инстанса,␍
# переменная окружения — переопределение.␍
databases["default"]["USER"] = os.environ.get(␍
"POSTGRES_USER", "chatballs_bootstrap"␍
)␍
databases["default"]["PASSWORD"] = env_secret(␍
"POSTGRES_PASSWORD", "postgres_password", "chatballs"␍
)␍
databases["platform"]["TEST"] = {"MIRROR": "default"}␍
return databases␍
import os
from django.core.exceptions import ImproperlyConfigured
from chatballs_backend.settings_env import env_secret
def _credentials() -> tuple[dict[str, str], dict[str, str]]:
# Имена ролей — константы продукта, а не настройка установки: их создаёт
# deploy/postgres/init-runtime-roles.sh при первом старте. Переменные
# окружения остаются переопределением для нестандартных установок.
users = {
"app": os.environ.get("POSTGRES_APP_USER", "chatballs_app"),
"platform": os.environ.get("POSTGRES_PLATFORM_USER", "chatballs_platform"),
"migration": os.environ.get(
"POSTGRES_MIGRATION_USER", "chatballs_migration"
),
}
# Пароли ролей генерирует первый старт стека в том с секретами; человек их
# не вводит и не хранит. Переменные окружения остаются переопределением.
fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs")
passwords = {
"app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback),
"platform": env_secret(
"POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback
),
"migration": env_secret(
"POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback
),
}
return users, passwords
def _pool_options(*, testing: bool) -> dict | None:
# ASGI-серверы исполняют ORM в короткоживущих потоках sync_to_async;
# persistent-соединения (CONN_MAX_AGE > 0) в таких потоках осиротевают и
# исчерпывают max_connections Postgres. Вместо них — psycopg pool на процесс:
# соединения возвращаются в пул независимо от потока и ограничены сверху.
# CHATBALLS_DB_POOL_MAX=0 отключает пул (короткоживущие соединения на запрос).
if testing:
return None
max_size = int(os.environ.get("CHATBALLS_DB_POOL_MAX", "4"))
if max_size <= 0:
return None
return {
"min_size": int(os.environ.get("CHATBALLS_DB_POOL_MIN", "1")),
"max_size": max_size,
"timeout": float(os.environ.get("CHATBALLS_DB_POOL_TIMEOUT", "10")),
}
def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
role = os.environ.get("CHATBALLS_DB_ROLE", "app").lower()
if role not in {"app", "platform", "migration"}:
raise ImproperlyConfigured("CHATBALLS_DB_ROLE must be app, platform or migration")
users, passwords = _credentials()
if not debug and not testing and len(set(users.values())) != 3:
raise ImproperlyConfigured(
"App, platform and migration database users must be distinct"
)
pool = _pool_options(testing=testing)
def config(selected_role: str) -> dict:
return {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "chatballs"),
"USER": users[selected_role],
"PASSWORD": passwords[selected_role],
"HOST": os.environ.get("POSTGRES_HOST", "postgres"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
# Пул несовместим с persistent-соединениями: с ним CONN_MAX_AGE
# обязан быть 0, а без пула persistent-режим возвращать нельзя
# (см. _pool_options).
"CONN_MAX_AGE": 0,
"OPTIONS": {"pool": dict(pool)} if pool else {},
}
databases = {
"default": config("migration" if testing else role),
"platform": config("platform"),
}
if testing:
# Тесты создают свою БД и подключаются владельцем кластера. Его пароль
# приходит оттуда же, откуда у остальных ролей: файл секрета инстанса,
# переменная окружения — переопределение.
databases["default"]["USER"] = os.environ.get(
"POSTGRES_USER", "chatballs_bootstrap"
)
databases["default"]["PASSWORD"] = env_secret(
"POSTGRES_PASSWORD", "postgres_password", "chatballs"
)
databases["platform"]["TEST"] = {"MIRROR": "default"}
return databases