mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
🔧 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:
1 parent
87a4359b04
commit
bd40ff38fe
22 files changed
+753
-746
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -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)"
|
||||
@@ -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",)
|
||||
+44
-44
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
+173
-173
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user