@@ -0,0 +1,31 @@ +# Generated by Django 5.0.9 on 2025-01-08 14:19 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0014_alter_businessaccount_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='UserSetting', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), + ('device', models.CharField(choices=[('mobile', 'Телефон'), ('desktop', 'ПК')], verbose_name='Устройство')), + ('type', models.CharField(choices=[('sidebar', 'Сайдбар')], verbose_name='Тип')), + ('value', models.JSONField(verbose_name='Значение')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_settings', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Настройка пользователя', + 'verbose_name_plural': 'Настройки пользователя', + 'unique_together': {('user', 'type', 'device')}, + }, + ), + ] @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ from authentication.models.business_group import BusinessGroup from authentication.models.choices import AccountPrivileges, InvitationStatus -from ml_model.models import BaseModel +from core.models import BaseModel class BusinessAccount(BaseModel): @@ -10,7 +10,7 @@ from authentication.models.business_account import BusinessAccount from authentication.models.business_group import BusinessGroup from authentication.models.choices import BusinessSector, UsageIntensity from authentication.models.whitelist import CompanyIPWhitelist -from ml_model.models import BaseModel +from core.models import BaseModel class BusinessUserHost(BaseModel): @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ -from ml_model.models import BaseModel +from core.models import BaseModel class EmailToken(BaseModel): @@ -1,5 +1,6 @@ import random from typing import TYPE_CHECKING, Optional +from uuid import uuid4 from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.core.exceptions import ObjectDoesNotExist @@ -236,3 +237,32 @@ def on_user_creation_signal(sender, instance, created, **kwargs): instance.last_name, None ) instance.save() + + +class UserSetting(models.Model): + class DeviceChoices(models.TextChoices): + MOBILE = 'mobile', 'Телефон' + DESKTOP = 'desktop', 'ПК' + + class TypeChoices(models.TextChoices): + SIDEBAR = 'sidebar', 'Сайдбар' + + id = models.UUIDField( + primary_key=True, editable=False, default=uuid4, verbose_name='ID' + ) + + user = models.ForeignKey( + CustomUserModel, + on_delete=models.CASCADE, + related_name='user_settings', + verbose_name='Пользователь', + ) + + device = models.CharField(choices=DeviceChoices.choices, verbose_name='Устройство') + type = models.CharField(choices=TypeChoices.choices, verbose_name='Тип') + value = models.JSONField(verbose_name='Значение') + + class Meta: + verbose_name = 'Настройка пользователя' + verbose_name_plural = 'Настройки пользователя' + unique_together = ['user', 'type', 'device'] @@ -0,0 +1,30 @@ +from typing import List +from uuid import UUID + +from ninja import Query, Router + +from authentication.schemas import ( + CreateUserSettingSchema, + UpdateUserSettingSchema, + UserSettingFilterSchema, + UserSettingSchema, +) +from authentication.security import SyncAuthBearer +from authentication.services.user_services import UserService + +router = Router(auth=SyncAuthBearer(), tags=['users']) + + +@router.get('settings/', tags=['users/settings'], response=List[UserSettingSchema]) +def get_user_settings(request, filters: UserSettingFilterSchema = Query(...)): + return UserService.list_settings(filters=filters.get_filter_expression()) + + +@router.post('settings/', tags=['users/settings'], response=UserSettingSchema) +def add_setting(request, data: CreateUserSettingSchema): + return UserService.add_setting(user_id=request.auth.uid, **data.model_dump()) + + +@router.put('settings/{id}', tags=['users/settings'], response={204: None}) +def update_setting(request, id: UUID, data: UpdateUserSettingSchema): + UserService.update_setting(setting_id=id, **data.model_dump()) @@ -1,6 +1,8 @@ -from typing import Tuple +from typing import Any, Tuple +from uuid import UUID from django.contrib.auth import authenticate, login, logout +from django.db.models import Q, QuerySet from django.db.transaction import atomic from django.utils.translation import gettext_lazy as _ from rest_framework.request import Request @@ -13,6 +15,7 @@ from authentication.models import ( TelegramUser, VKUser, ) +from authentication.models.user import UserSetting from authentication.selectors.email_token_selector import EmailTokenSelector from authentication.selectors.user_selector import UserSelector from authentication.serializers import ( @@ -280,6 +283,22 @@ class UserService: emails__contains=[request.query_params['email']] ).exists() + @classmethod + def list_settings(cls, filters: Q = Q()) -> QuerySet[UserSetting]: + return UserSetting.objects.filter(filters) + + @classmethod + def add_setting( + cls, user_id: UUID, device: str, type: str, value: Any + ) -> UserSetting: + return UserSetting.objects.create( + user_id=user_id, device=device, type=type, value=value + ) + + @classmethod + def update_setting(cls, setting_id: UUID, value: Any) -> None: + UserSetting.objects.filter(id=setting_id).update(value=value) + # For Social Auth pipeline def update_profile_picture_social(*args, **kwargs): @@ -0,0 +1,28 @@ +from typing import List, Optional + +from ninja import Field, FilterSchema, ModelSchema + +from authentication.models.user import UserSetting + + +class UserSettingFilterSchema(FilterSchema): + device: Optional[str] = None + types: List[str] = Field(None, q='type__in') + + +class UserSettingSchema(ModelSchema): + class Meta: + model = UserSetting + exclude = ('user',) + + +class CreateUserSettingSchema(ModelSchema): + class Meta: + model = UserSetting + fields = ('device', 'type', 'value') + + +class UpdateUserSettingSchema(ModelSchema): + class Meta: + model = UserSetting + fields = ('value',) @@ -8,7 +8,7 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') application = get_asgi_application() -from tools.copywrite.router import ws_router as copywrite_ws_router # noqa: E402 +from tools.copywrite.routes.v1 import ws_router as copywrite_ws_router # noqa: E402 application = ProtocolTypeRouter( { @@ -38,6 +38,7 @@ CORE_APP = 'core.apps.CoreConfig' SYSTEM_APPS = [ 'django.contrib.auth', + 'polymorphic', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', @@ -1,14 +1,40 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin +from django.core.exceptions import ObjectDoesNotExist from django.urls import include, path +from django.utils.translation import gettext_lazy as _ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from ninja import NinjaAPI +from authentication.exceptions import InvalidPassword, InvalidToken, InvalidUsername from backend.public import urlpatterns as public_urlpatterns -api = NinjaAPI(title='AIR API', version='2.0.0') -api.add_router('copywrite/', 'tools.copywrite.router.router') +api = NinjaAPI(title='AIR API', version='1.0.0') +api.add_router('copywrite/', 'tools.copywrite.routes.v1.router') +api.add_router('users/', 'authentication.routes.v1.router') + + +@api.exception_handler(ObjectDoesNotExist) +def object_does_not_exists_error_handler(request, exc: ObjectDoesNotExist): + return api.create_response( + request, {'message': _('Requested object does not exists')}, status=404 + ) + + +@api.exception_handler(InvalidToken) +def invalid_token_error_handler(request, exc: InvalidToken): + return api.create_response(request, {'message': _('Token is invalid')}, status=401) + + +@api.exception_handler(InvalidPassword) +def invalid_password_error_handler(request, exc: InvalidPassword): + return api.create_response(request, {'message': _('Wrong password')}, status=401) + + +@api.exception_handler(InvalidUsername) +def invalid_username_error_handler(request, exc: InvalidUsername): + return api.create_response(request, {'message': _('Wrong username')}, status=401) urlpatterns = [ @@ -0,0 +1,33 @@ +ALPHABET = { + 'а': 'a', + 'б': 'b', + 'в': 'v', + 'г': 'g', + 'д': 'd', + 'е': 'e', + 'ё': 'yo', + 'ж': 'zh', + 'з': 'z', + 'и': 'i', + 'й': 'j', + 'к': 'k', + 'л': 'l', + 'м': 'm', + 'н': 'n', + 'о': 'o', + 'п': 'p', + 'р': 'r', + 'с': 's', + 'т': 't', + 'у': 'u', + 'ф': 'f', + 'х': 'kh', + 'ц': 'ts', + 'ч': 'ch', + 'ш': 'sh', + 'щ': 'shch', + 'ы': 'i', + 'э': 'e', + 'ю': 'yu', + 'я': 'ya', +} @@ -0,0 +1,7 @@ +from django.template.defaultfilters import slugify as django_slugify + +from core.constants import ALPHABET + + +def slugify(s: str): + return django_slugify(''.join(ALPHABET.get(w, w) for w in s.lower())) @@ -0,0 +1,55 @@ +# Generated by Django 5.0.9 on 2025-01-09 09:29 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ml_model', '0039_alter_neuronmodel_slug'), + ] + + operations = [ + migrations.AlterField( + model_name='modelcategory', + name='title', + field=models.CharField(max_length=100, verbose_name='Title'), + ), + migrations.AlterField( + model_name='modelversion', + name='slug', + field=models.CharField(max_length=32, unique=True, verbose_name='Slug'), + ), + migrations.CreateModel( + name='ModelConfiguration', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), + ('oid', models.UUIDField()), + ('ct', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='model_configurations', to='ml_model.neuronmodel', to_field='slug', verbose_name='Модель')), + ('version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='version_configurations', to='ml_model.modelversion', verbose_name='Версия модели')), + ], + options={ + 'verbose_name': 'Конфигурация модели', + 'verbose_name_plural': 'Конфигурации моделей', + 'unique_together': {('oid', 'ct')}, + }, + ), + migrations.CreateModel( + name='ConfigurationParameter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.JSONField(verbose_name='Значение')), + ('parameter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='model_configuration_parameters', to='ml_model.modelparameter', verbose_name='Параметр модели')), + ('configuration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='configuration_parameters', to='ml_model.modelconfiguration', verbose_name='Конфигурация')), + ], + options={ + 'verbose_name': 'Параметр конфигурации', + 'verbose_name_plural': 'Параметры конфигураций', + 'unique_together': {('configuration', 'parameter')}, + }, + ), + ] @@ -0,0 +1,19 @@ +# Generated by Django 5.0.9 on 2025-01-09 09:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ml_model', '0040_alter_modelcategory_title_alter_modelversion_slug_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='modelconfiguration', + name='version', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='version_configurations', to='ml_model.modelversion', to_field='slug', verbose_name='Версия модели'), + ), + ] @@ -1,11 +1,16 @@ import itertools +import base64 import time +import logging + from datetime import timedelta from decimal import Decimal +from math import ceil from io import BufferedReader, BytesIO from typing import Any, Dict, Generator, List, Optional -from django.core.files.uploadedfile import InMemoryUploadedFile +import filetype +import tiktoken from langchain import hub from langchain.agents import AgentExecutor, create_structured_chat_agent from langchain.chains import ConversationChain @@ -17,9 +22,15 @@ from langchain_openai.chat_models import ChatOpenAI from PIL import Image from backend import settings -from messages.models import Message from ml_model.constants import TEMPORARY_TEST_TEXT -from ml_model.models import ModelCategory, ModelInput, ModelParameter, ModelVersion +from messages.models import Message, BaseStore +from ml_model.models import ( + ModelCategory, + ModelConfiguration, + ModelInput, + ModelParameter, + ModelVersion, +) from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector @@ -43,9 +54,6 @@ class Chatgpt(SimpleService): ModelVersion(name='GPT-4o1 Mini', slug='o1-mini'), ModelVersion(name='GPT-4omni Mini', slug='gpt-4o-mini'), ModelVersion(name='GPT-4omni', slug='gpt-4o'), - ModelVersion(name='GPT-4 Turbo', slug='gpt-4-turbo'), - ModelVersion(name='GPT-4', slug='gpt-4'), - ModelVersion(name='GPT-3.5', slug='gpt-3.5-turbo'), ] inputs = [ @@ -82,15 +90,16 @@ class Chatgpt(SimpleService): category = ModelCategory(title='Чат-боты', slug='chat-bots') TOKENS_COST = { - 'gpt-3.5-turbo': Decimal('0.0085'), - 'gpt-4': Decimal('0.08925'), - 'gpt-4-turbo': Decimal('0.25'), - 'gpt-4o': Decimal('0.075'), - 'o1-preview': Decimal('0.09'), - 'o1-mini': Decimal('0.02'), - 'gpt-4o-mini': Decimal('0.03'), + 'o1-preview': {'input': Decimal('0.008250'), 'output': Decimal('0.033000')}, + 'o1-mini': {'input': Decimal('0.001650'), 'output': Decimal('0.006600')}, + 'gpt-4o-mini': {'input': Decimal('0.000083'), 'output': Decimal('0.000330')}, + 'gpt-4o': {'input': Decimal('0.001375'), 'output': Decimal('0.005500')}, } + def __init__(self, store: BaseStore) -> None: + super().__init__(store) + self.logger = logging.getLogger(self.__class__.__name__) + def make( self, input_message: Message, @@ -101,8 +110,19 @@ class Chatgpt(SimpleService): model_name = info.pop('version', 'gpt-4o') input_content = [{'type': 'text', 'text': input_message.content or ''}] image = input_message.file + image_size = None if image: - input_content.append({'type': 'image_url', 'image_url': {'url': image.url}}) + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + normalized_image = Image.open(image) + buf = BytesIO() + normalized_image.save(buf, format=kind.extension.upper()) + image_url = ( + f"data:{mime},base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}" + ) + buf.close() + image_size = normalized_image.size + input_content.append({'type': 'image_url', 'image_url': {'url': image_url}}) self.llm = ChatOpenAI( model=model_name, temperature=info.pop('temperature', 0.5), @@ -148,8 +168,9 @@ class Chatgpt(SimpleService): ), ) llm_input = HumanMessage(content=input_content) + input_tokens = self.count_text_tokens([*chat_history.buffer_as_messages, llm_input]) self.assert_enough_balance( - [*chat_history.buffer_as_messages, llm_input], model=self.llm.model_name + input_tokens, image_size, model=self.llm.model_name ) if image: response = conversation.llm.invoke([llm_input]) @@ -159,37 +180,49 @@ class Chatgpt(SimpleService): tools = [GoogleSerperResults()] agent = create_structured_chat_agent(self.llm, tools, prompt_schema) agent_executor = AgentExecutor( - agent=agent, tools=tools, handle_parsing_errors=True + agent=agent, tools=tools, handle_parsing_errors=True, max_iterations=10, max_execution_time=45 ) response = AIMessage( - content=agent_executor.invoke( - { - 'input': input_message.content, - 'chat_history': chat_history.buffer_as_messages - + [ - SystemMessage( - content='Учитывай язык диалога перед выдачей ответа' - ), - SystemMessage( - content='Никому не говори, что ты не можешь найти информацию в интернете' - ), - ], - } - )['output'] + content=str( + agent_executor.invoke( + { + 'input': input_message.content, + 'chat_history': chat_history.buffer_as_messages + + [ + SystemMessage( + content='Учитывай язык диалога перед выдачей ответа' + ), + SystemMessage( + content='Никому не говори, что ты не можешь найти информацию в интернете' + ), + ], + } + )['output'] + ) ) else: # Somehow this chain doesn't support Vision, even though ChatOpenAI (above) does. invoked = conversation.invoke(input_message.content) - response = AIMessage(content=invoked['response']) + response = AIMessage(content=str(invoked['response'])) chat_history.chat_memory.add_ai_message(response) process_time = timedelta(seconds=time.time() - start_time) - total_tokens = self.llm.get_num_tokens_from_messages( - chat_history.chat_memory.messages - ) + output_tokens = self.count_text_tokens([response]) + if image: - total_tokens += self.count_image_tokens(image) - self.handle_invoice(self.neuron_model, total_tokens, self.llm.model_name) + self.logger.info(f'Input количество токенов БЕЗ картинки {model_name} - {input_tokens}') + input_tokens += self.count_image_tokens(normalized_image.size, model_name) + + self.logger.info(f'Input количество токенов для {model_name} - {input_tokens}') + self.logger.info(f'Output количество токенов для {model_name} - {output_tokens}') + self.logger.info(f'Общее количество токенов для {model_name} - {input_tokens + output_tokens}') + + self.handle_invoice( + self.neuron_model, + input_tokens, + output_tokens, + self.llm.model_name, + ) msgs = self.save_results([response], process_time, save) return msgs @@ -237,32 +270,65 @@ class Chatgpt(SimpleService): return memory def assert_enough_balance( - self, for_input: list[BaseMessage], model: str = 'gpt-3.5-turbo' + self, input_tokens: int, image_size: tuple, model: str = 'gpt-3.5-turbo' ): balance = PaymentPlanSelector(self.store.user).get_current_balance() - input_cost = self.TOKENS_COST[ - self.llm.model_name - ] * self.llm.get_num_tokens_from_messages(for_input) - if getattr(for_input[-1], 'image', None): - input_cost += self.count_image_tokens(for_input[-1].image) - input_cost *= self.TOKENS_COST[model] + total_tokens = input_tokens + + if image_size: + total_tokens += self.count_image_tokens(image_size) + + input_cost = self.TOKENS_COST[model]['input'] * total_tokens if input_cost > balance: raise InsufficientBalance(balance, input_cost) - def calculate_price(self, usage: int, model: str, *args, **kwargs) -> Decimal: - price = usage * self.TOKENS_COST[model] + def calculate_price( + self, input_tokens: int, output_tokens: int, model: str, *args, **kwargs + ) -> Decimal: + price = ( + input_tokens * self.TOKENS_COST[model]['input'] + + output_tokens * self.TOKENS_COST[model]['output'] + ) return price.quantize(Decimal('0.1'), rounding='ROUND_UP') def count_image_tokens( - self, image: InMemoryUploadedFile, high_resolution: bool = True + self, image_size: tuple, model_version: str = 'gpt-4o' ) -> int: - TILE_SIZE = 512 * 512 - RESOLUTION_MULTIPLIER = 170 if high_resolution else 85 - minio_image = Image.open(BytesIO(image.read())) - width, height = minio_image.size - area = width * height - num_of_tiles = (area // TILE_SIZE) + 1 - return num_of_tiles * RESOLUTION_MULTIPLIER + 85 + extra_tokens = { + 'gpt-4o': { + 'tile_tokens': 170, + 'base_tokens': 85, + }, + 'gpt-4o-mini': { + 'tile_tokens': 5667, + 'base_tokens': 2833, + } + } + width, height = image_size + + if max(width, height) > 2048: + a_ratio = width / height + width, height = (2048, int(2048 / a_ratio)) if a_ratio > 1 else (int(2048 * a_ratio), 2048) + if width >= height and height > 768: + width, height = int((768 / height) * width), 768 + elif height > width and width > 768: + width, height = 768, int((768 / width) * height) + tiles_size = ceil(width / 512) * ceil(height / 512) + + return extra_tokens[model_version]['base_tokens'] + extra_tokens[model_version]['tile_tokens'] * tiles_size + + def count_text_tokens(self, messages: list[BaseMessage]) -> int: + encoding = tiktoken.get_encoding("cl100k_base") + total_tokens = 0 + + for message in messages: + total_tokens += len( + encoding.encode( + message.content if isinstance(message.content, str) + else message.content[0]['text'] + ) + ) + return total_tokens def save_results( self, results: list[BaseMessage], elapsed_time: timedelta, save: bool = True @@ -324,16 +390,27 @@ class Chatgpt(SimpleService): image: Optional[BufferedReader] = None, context_messages: List[str] = [], *, + configuration: Optional[ModelConfiguration] = None, stream: bool = False, - parameters: List[Dict[str, Any]] = [], ) -> str | Generator[str, None, None]: # with httpx.Client() as client: # data = {} - # if not stream: - # client.post('https://openai.com/api/chat/completions', json=data) - # resp = client.post( - # 'https://openai.com/api/chat/completions', json=data | {'stream': True} - # ) + # with client.stream( + # 'POST', + # 'https://api.openai.com/v1/chat/completions', + # headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, + # json={ + # 'model': 'gpt-4o-mini', + # 'messages': [ + # { + # 'role': 'user', + # 'content': 'Hello! Generate a tale for 1500 symbols', + # } + # ], + # 'stream': True, + # }, + # ) as resp: + # ... + for chunk in itertools.batched(TEMPORARY_TEST_TEXT, 10): - time.sleep(0.3) yield ''.join(chunk) @@ -8,7 +8,6 @@ from django.core.files import File from backend import settings from messages.models import BaseStore, Message -from ml_model.exceptions.external_api import ExternalAPIException from ml_model.models import ( ModelCategory, ModelInput, @@ -123,7 +122,7 @@ class Recraft(SimpleService): } data = {'input': payload} response = requests.post( - url=f'{self.generate_url}{payload['version']}/predictions', + url=f'{self.generate_url}{payload.get('version', 'recraft-v3')}/predictions', headers=headers, json=data, ) @@ -186,7 +185,7 @@ class Recraft(SimpleService): 'линогравюра': 'linocut' } start_time = time.time() - extension = '.svg' if input_message.info['version'] == self.versions[1].slug else '.png' + extension = '.svg' if input_message.info.get('version', 'recraft-v3') == self.versions[1].slug else '.png' size = self._get_size(input_message.info.pop('width', 1024), input_message.info.pop('height', 1024)) callback_data = dict( { @@ -195,7 +194,7 @@ class Recraft(SimpleService): f'Input description: {self.translate_prompt(input_message.content)}' ), 'size': size, - 'style': styles.get(input_message.info.pop('style'), 'любой'), + 'style': styles.get(input_message.info.get('style', 'любой')), **input_message.info } ) @@ -1,6 +1,6 @@ from django.contrib import admin from django.http.response import HttpResponse as HttpResponse -from import_export.admin import ExportActionModelAdmin, ImportExportModelAdmin +from import_export.admin import ImportExportMixin from ordered_model.admin import ( OrderedInlineModelAdminMixin, OrderedModelAdmin, @@ -8,7 +8,9 @@ from ordered_model.admin import ( ) from ml_model.models import ( + ConfigurationParameter, ModelCategory, + ModelConfiguration, ModelInput, ModelParameter, ModelPaymentRule, @@ -65,10 +67,7 @@ class ModelCategoryAdmin(admin.ModelAdmin): @admin.register(NeuronModel) class NeuronModelModelAdmin( - OrderedInlineModelAdminMixin, - ImportExportModelAdmin, - ExportActionModelAdmin, - OrderedModelAdmin, + OrderedInlineModelAdminMixin, ImportExportMixin, OrderedModelAdmin ): list_display = ['title', 'is_active', 'category', 'move_up_down_links'] resource_classes = (NeuronModelResource,) @@ -89,3 +88,18 @@ class NeuronModelModelAdmin( @admin.display(description='Категория', boolean=True) def category(self, obj: NeuronModel): return obj.category.title + + +class ConfigurationParameterInline(admin.TabularInline): + model = ConfigurationParameter + extra = 1 + can_delete = False + + def has_change_permission(self, request, obj=...): + return False + + +@admin.register(ModelConfiguration) +class ModelConfigurationAdmin(admin.ModelAdmin): + fields = ('model', 'version') + inlines = (ConfigurationParameterInline,) @@ -5,6 +5,55 @@ dolor sit amet, consectetur adipiscing elit. Proin venenatis ornare porta. Vivam

Aenean aliquet finibus lobortis.

Nam convallis mi a luctus laoreet. Etiam suscipit egestas posuere. Aenean eget facilisis augue, ut pulvinar lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc pharetra nisl non nulla tristique gravida. Phasellus nunc eros, cursus in nibh a, suscipit vehicula justo. Sed in mi in nulla efficitur faucibus. Maecenas at vestibulum nunc, ultricies elementum sapien. Proin pulvinar ultrices placerat. Duis convallis lobortis pellentesque. Donec ornare ante mauris, a consectetur magna lobortis consequat. Quisque gravida eget neque eleifend bibendum. +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec rutrum viverra suscipit. Etiam nec laoreet quam. Aenean in ligula sit amet nibh consectetur porta. Cras molestie nisl a odio lacinia, ac ornare odio hendrerit. Mauris et pulvinar dolor. Morbi pretium luctus sem, ac congue eros ornare eget. Pellentesque laoreet elementum felis id mollis. Maecenas velit nulla, gravida sodales lectus in, fermentum auctor justo. Sed euismod maximus ex, vel pharetra massa ultricies vel. Curabitur interdum cursus quam ut placerat. Phasellus eleifend purus sed nisi pellentesque condimentum. Fusce finibus, felis quis mattis tempus, tortor elit tincidunt massa, non interdum tellus neque id mauris. Morbi dapibus volutpat lectus sed pellentesque. Curabitur sagittis lacinia quam eu viverra. +In sit amet nunc sed urna aliquet vehicula id vel justo. Duis vel massa eleifend, tristique ante in, ullamcorper tellus. Donec egestas lacus eu libero sodales luctus. Duis id maximus arcu, non dictum felis. Cras nisl odio, viverra in arcu at, sagittis gravida augue. Aliquam quis metus vel urna finibus sodales. Praesent a porttitor magna. Integer eu est ac ligula luctus consequat. + +

Lorem ipsum

+dolor sit amet, consectetur adipiscing elit. Proin venenatis ornare porta. Vivamus laoreet, eros vitae consectetur semper, nisi purus dignissim libero, et aliquam nisi sem eu nibh. Praesent malesuada augue eget purus interdum congue. Vestibulum ut maximus dolor. Donec vitae enim et mi ultrices faucibus. Vestibulum quis sollicitudin arcu, eget molestie sapien. Pellentesque id eros erat. Vestibulum vitae dapibus diam, non ornare eros. Maecenas gravida vulputate mi, id auctor sapien faucibus in. + +

Aenean aliquet finibus lobortis.

+Nam convallis mi a luctus laoreet. Etiam suscipit egestas posuere. Aenean eget facilisis augue, ut pulvinar lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc pharetra nisl non nulla tristique gravida. Phasellus nunc eros, cursus in nibh a, suscipit vehicula justo. Sed in mi in nulla efficitur faucibus. Maecenas at vestibulum nunc, ultricies elementum sapien. Proin pulvinar ultrices placerat. Duis convallis lobortis pellentesque. Donec ornare ante mauris, a consectetur magna lobortis consequat. Quisque gravida eget neque eleifend bibendum. + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec rutrum viverra suscipit. Etiam nec laoreet quam. Aenean in ligula sit amet nibh consectetur porta. Cras molestie nisl a odio lacinia, ac ornare odio hendrerit. Mauris et pulvinar dolor. Morbi pretium luctus sem, ac congue eros ornare eget. Pellentesque laoreet elementum felis id mollis. Maecenas velit nulla, gravida sodales lectus in, fermentum auctor justo. Sed euismod maximus ex, vel pharetra massa ultricies vel. Curabitur interdum cursus quam ut placerat. Phasellus eleifend purus sed nisi pellentesque condimentum. Fusce finibus, felis quis mattis tempus, tortor elit tincidunt massa, non interdum tellus neque id mauris. Morbi dapibus volutpat lectus sed pellentesque. Curabitur sagittis lacinia quam eu viverra. +In sit amet nunc sed urna aliquet vehicula id vel justo. Duis vel massa eleifend, tristique ante in, ullamcorper tellus. Donec egestas lacus eu libero sodales luctus. Duis id maximus arcu, non dictum felis. Cras nisl odio, viverra in arcu at, sagittis gravida augue. Aliquam quis metus vel urna finibus sodales. Praesent a porttitor magna. Integer eu est ac ligula luctus consequat. + + +

Lorem ipsum

+dolor sit amet, consectetur adipiscing elit. Proin venenatis ornare porta. Vivamus laoreet, eros vitae consectetur semper, nisi purus dignissim libero, et aliquam nisi sem eu nibh. Praesent malesuada augue eget purus interdum congue. Vestibulum ut maximus dolor. Donec vitae enim et mi ultrices faucibus. Vestibulum quis sollicitudin arcu, eget molestie sapien. Pellentesque id eros erat. Vestibulum vitae dapibus diam, non ornare eros. Maecenas gravida vulputate mi, id auctor sapien faucibus in. + +

Aenean aliquet finibus lobortis.

+Nam convallis mi a luctus laoreet. Etiam suscipit egestas posuere. Aenean eget facilisis augue, ut pulvinar lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc pharetra nisl non nulla tristique gravida. Phasellus nunc eros, cursus in nibh a, suscipit vehicula justo. Sed in mi in nulla efficitur faucibus. Maecenas at vestibulum nunc, ultricies elementum sapien. Proin pulvinar ultrices placerat. Duis convallis lobortis pellentesque. Donec ornare ante mauris, a consectetur magna lobortis consequat. Quisque gravida eget neque eleifend bibendum. + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec rutrum viverra suscipit. Etiam nec laoreet quam. Aenean in ligula sit amet nibh consectetur porta. Cras molestie nisl a odio lacinia, ac ornare odio hendrerit. Mauris et pulvinar dolor. Morbi pretium luctus sem, ac congue eros ornare eget. Pellentesque laoreet elementum felis id mollis. Maecenas velit nulla, gravida sodales lectus in, fermentum auctor justo. Sed euismod maximus ex, vel pharetra massa ultricies vel. Curabitur interdum cursus quam ut placerat. Phasellus eleifend purus sed nisi pellentesque condimentum. Fusce finibus, felis quis mattis tempus, tortor elit tincidunt massa, non interdum tellus neque id mauris. Morbi dapibus volutpat lectus sed pellentesque. Curabitur sagittis lacinia quam eu viverra. +In sit amet nunc sed urna aliquet vehicula id vel justo. Duis vel massa eleifend, tristique ante in, ullamcorper tellus. Donec egestas lacus eu libero sodales luctus. Duis id maximus arcu, non dictum felis. Cras nisl odio, viverra in arcu at, sagittis gravida augue. Aliquam quis metus vel urna finibus sodales. Praesent a porttitor magna. Integer eu est ac ligula luctus consequat. + + +

Lorem ipsum

+dolor sit amet, consectetur adipiscing elit. Proin venenatis ornare porta. Vivamus laoreet, eros vitae consectetur semper, nisi purus dignissim libero, et aliquam nisi sem eu nibh. Praesent malesuada augue eget purus interdum congue. Vestibulum ut maximus dolor. Donec vitae enim et mi ultrices faucibus. Vestibulum quis sollicitudin arcu, eget molestie sapien. Pellentesque id eros erat. Vestibulum vitae dapibus diam, non ornare eros. Maecenas gravida vulputate mi, id auctor sapien faucibus in. + +

Aenean aliquet finibus lobortis.

+Nam convallis mi a luctus laoreet. Etiam suscipit egestas posuere. Aenean eget facilisis augue, ut pulvinar lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc pharetra nisl non nulla tristique gravida. Phasellus nunc eros, cursus in nibh a, suscipit vehicula justo. Sed in mi in nulla efficitur faucibus. Maecenas at vestibulum nunc, ultricies elementum sapien. Proin pulvinar ultrices placerat. Duis convallis lobortis pellentesque. Donec ornare ante mauris, a consectetur magna lobortis consequat. Quisque gravida eget neque eleifend bibendum. + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec rutrum viverra suscipit. Etiam nec laoreet quam. Aenean in ligula sit amet nibh consectetur porta. Cras molestie nisl a odio lacinia, ac ornare odio hendrerit. Mauris et pulvinar dolor. Morbi pretium luctus sem, ac congue eros ornare eget. Pellentesque laoreet elementum felis id mollis. Maecenas velit nulla, gravida sodales lectus in, fermentum auctor justo. Sed euismod maximus ex, vel pharetra massa ultricies vel. Curabitur interdum cursus quam ut placerat. Phasellus eleifend purus sed nisi pellentesque condimentum. Fusce finibus, felis quis mattis tempus, tortor elit tincidunt massa, non interdum tellus neque id mauris. Morbi dapibus volutpat lectus sed pellentesque. Curabitur sagittis lacinia quam eu viverra. +In sit amet nunc sed urna aliquet vehicula id vel justo. Duis vel massa eleifend, tristique ante in, ullamcorper tellus. Donec egestas lacus eu libero sodales luctus. Duis id maximus arcu, non dictum felis. Cras nisl odio, viverra in arcu at, sagittis gravida augue. Aliquam quis metus vel urna finibus sodales. Praesent a porttitor magna. Integer eu est ac ligula luctus consequat. + + +

Lorem ipsum

+dolor sit amet, consectetur adipiscing elit. Proin venenatis ornare porta. Vivamus laoreet, eros vitae consectetur semper, nisi purus dignissim libero, et aliquam nisi sem eu nibh. Praesent malesuada augue eget purus interdum congue. Vestibulum ut maximus dolor. Donec vitae enim et mi ultrices faucibus. Vestibulum quis sollicitudin arcu, eget molestie sapien. Pellentesque id eros erat. Vestibulum vitae dapibus diam, non ornare eros. Maecenas gravida vulputate mi, id auctor sapien faucibus in. + +

Aenean aliquet finibus lobortis.

+Nam convallis mi a luctus laoreet. Etiam suscipit egestas posuere. Aenean eget facilisis augue, ut pulvinar lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc pharetra nisl non nulla tristique gravida. Phasellus nunc eros, cursus in nibh a, suscipit vehicula justo. Sed in mi in nulla efficitur faucibus. Maecenas at vestibulum nunc, ultricies elementum sapien. Proin pulvinar ultrices placerat. Duis convallis lobortis pellentesque. Donec ornare ante mauris, a consectetur magna lobortis consequat. Quisque gravida eget neque eleifend bibendum. + +Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec rutrum viverra suscipit. Etiam nec laoreet quam. Aenean in ligula sit amet nibh consectetur porta. Cras molestie nisl a odio lacinia, ac ornare odio hendrerit. Mauris et pulvinar dolor. Morbi pretium luctus sem, ac congue eros ornare eget. Pellentesque laoreet elementum felis id mollis. Maecenas velit nulla, gravida sodales lectus in, fermentum auctor justo. Sed euismod maximus ex, vel pharetra massa ultricies vel. Curabitur interdum cursus quam ut placerat. Phasellus eleifend purus sed nisi pellentesque condimentum. Fusce finibus, felis quis mattis tempus, tortor elit tincidunt massa, non interdum tellus neque id mauris. Morbi dapibus volutpat lectus sed pellentesque. Curabitur sagittis lacinia quam eu viverra. +In sit amet nunc sed urna aliquet vehicula id vel justo. Duis vel massa eleifend, tristique ante in, ullamcorper tellus. Donec egestas lacus eu libero sodales luctus. Duis id maximus arcu, non dictum felis. Cras nisl odio, viverra in arcu at, sagittis gravida augue. Aliquam quis metus vel urna finibus sodales. Praesent a porttitor magna. Integer eu est ac ligula luctus consequat. + + +

Lorem ipsum

+dolor sit amet, consectetur adipiscing elit. Proin venenatis ornare porta. Vivamus laoreet, eros vitae consectetur semper, nisi purus dignissim libero, et aliquam nisi sem eu nibh. Praesent malesuada augue eget purus interdum congue. Vestibulum ut maximus dolor. Donec vitae enim et mi ultrices faucibus. Vestibulum quis sollicitudin arcu, eget molestie sapien. Pellentesque id eros erat. Vestibulum vitae dapibus diam, non ornare eros. Maecenas gravida vulputate mi, id auctor sapien faucibus in. + +

Aenean aliquet finibus lobortis.

+Nam convallis mi a luctus laoreet. Etiam suscipit egestas posuere. Aenean eget facilisis augue, ut pulvinar lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nunc pharetra nisl non nulla tristique gravida. Phasellus nunc eros, cursus in nibh a, suscipit vehicula justo. Sed in mi in nulla efficitur faucibus. Maecenas at vestibulum nunc, ultricies elementum sapien. Proin pulvinar ultrices placerat. Duis convallis lobortis pellentesque. Donec ornare ante mauris, a consectetur magna lobortis consequat. Quisque gravida eget neque eleifend bibendum. + Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec rutrum viverra suscipit. Etiam nec laoreet quam. Aenean in ligula sit amet nibh consectetur porta. Cras molestie nisl a odio lacinia, ac ornare odio hendrerit. Mauris et pulvinar dolor. Morbi pretium luctus sem, ac congue eros ornare eget. Pellentesque laoreet elementum felis id mollis. Maecenas velit nulla, gravida sodales lectus in, fermentum auctor justo. Sed euismod maximus ex, vel pharetra massa ultricies vel. Curabitur interdum cursus quam ut placerat. Phasellus eleifend purus sed nisi pellentesque condimentum. Fusce finibus, felis quis mattis tempus, tortor elit tincidunt massa, non interdum tellus neque id mauris. Morbi dapibus volutpat lectus sed pellentesque. Curabitur sagittis lacinia quam eu viverra. In sit amet nunc sed urna aliquet vehicula id vel justo. Duis vel massa eleifend, tristique ante in, ullamcorper tellus. Donec egestas lacus eu libero sodales luctus. Duis id maximus arcu, non dictum felis. Cras nisl odio, viverra in arcu at, sagittis gravida augue. Aliquam quis metus vel urna finibus sodales. Praesent a porttitor magna. Integer eu est ac ligula luctus consequat. """ @@ -0,0 +1,12 @@ +from django.contrib.contenttypes.admin import GenericStackedInline + +from ml_model.models import ModelConfiguration + + +class ModelConfigurationInline(GenericStackedInline): + model = ModelConfiguration + ct_fk_field = 'oid' + ct_field = 'ct' + max_num = 1 + extra = 0 + show_change_link = True @@ -1,3 +1,7 @@ +from uuid import uuid4 + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import F, QuerySet from django.utils.translation import gettext_lazy as _ @@ -8,7 +12,7 @@ from core.models import BaseModel class ModelCategory(models.Model): - title = models.CharField(max_length=100, blank=True, verbose_name=_('Title')) + title = models.CharField(max_length=100, verbose_name=_('Title')) slug = models.SlugField(max_length=100, unique=True, verbose_name=_('Slug')) @property @@ -120,7 +124,7 @@ class ModelVersion(ModelDepends, OrderedModel): description = models.CharField( max_length=128, null=True, blank=True, verbose_name=_('Description') ) - slug = models.CharField(max_length=32, verbose_name=_('Slug')) + slug = models.CharField(max_length=32, unique=True, verbose_name=_('Slug')) default = models.BooleanField(default=False, verbose_name=_('Default')) order_with_respect_to = 'model' @@ -277,3 +281,60 @@ class ModelPaymentRule(ModelDepends, ModelVersionsDepends): class Meta: verbose_name = _('Payment Rule') verbose_name_plural = _('Payment Rules') + + +class ModelConfiguration(models.Model): + id = models.UUIDField( + primary_key=True, default=uuid4, editable=False, verbose_name='ID' + ) + model = models.ForeignKey( + NeuronModel, + on_delete=models.PROTECT, + to_field='slug', + related_name='model_configurations', + verbose_name='Модель', + ) + version = models.ForeignKey( + ModelVersion, + on_delete=models.PROTECT, + to_field='slug', + null=True, + blank=True, + related_name='version_configurations', + verbose_name='Версия модели', + ) + + ct = models.ForeignKey(ContentType, on_delete=models.CASCADE) + oid = models.UUIDField() + obj = GenericForeignKey('ct', 'oid') + + @property + def parameters(self) -> QuerySet['ConfigurationParameter']: + return self.configuration_parameters.all() + + class Meta: + verbose_name = 'Конфигурация модели' + verbose_name_plural = 'Конфигурации моделей' + unique_together = ('oid', 'ct') + + +class ConfigurationParameter(models.Model): + configuration = models.ForeignKey( + ModelConfiguration, + on_delete=models.CASCADE, + related_name='configuration_parameters', + verbose_name='Конфигурация', + ) + + parameter = models.ForeignKey( + ModelParameter, + on_delete=models.CASCADE, + related_name='model_configuration_parameters', + verbose_name='Параметр модели', + ) + value = models.JSONField(verbose_name='Значение') + + class Meta: + verbose_name = 'Параметр конфигурации' + verbose_name_plural = 'Параметры конфигураций' + unique_together = ['configuration', 'parameter'] @@ -0,0 +1,27 @@ +from typing import List, Optional + +from ninja import ModelSchema + +from ml_model.models import ( + ConfigurationParameter, + ModelConfiguration, +) + + +class ConfigurationParameterSchema(ModelSchema): + class Meta: + model = ConfigurationParameter + exclude = ('configuration',) + + +class ModelConfigurationSchema(ModelSchema): + parameters: List[ConfigurationParameterSchema] = [] + model_id: str + version_id: Optional[str] = None + + class Meta: + model = ModelConfiguration + exclude = ('ct', 'oid', 'obj', 'model', 'version') + + class Config: + protected_namespaces = () @@ -6,7 +6,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import PeriodicTask -from ml_model.models import BaseModel, NeuronModel +from core.models import BaseModel +from ml_model.models import NeuronModel class PaymentPlan(BaseModel): @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ -from ml_model.models import BaseModel +from core.models import BaseModel class UserPaymentMethod(BaseModel): @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ -from ml_model.models import BaseModel +from core.models import BaseModel class ErrorReport(BaseModel): @@ -1,7 +1,7 @@ from django.db import models from authentication.models import CustomUserModel -from ml_model.models import BaseModel +from core.models import BaseModel class RequestReponseLog(BaseModel): @@ -10,7 +10,11 @@ class RequestReponseLog(BaseModel): from_ip = models.GenericIPAddressField(verbose_name='От IP адреса') request_body = models.TextField(null=True, blank=True, verbose_name='Тело запроса') request_user = models.ForeignKey( - CustomUserModel, verbose_name='От пользователя', on_delete=models.CASCADE, null=True, blank=True + CustomUserModel, + verbose_name='От пользователя', + on_delete=models.CASCADE, + null=True, + blank=True, ) response_body = models.TextField(null=True, blank=True, verbose_name='Тело ответа') status_code = models.IntegerField(verbose_name='Код ответа') @@ -1,4 +1,4 @@ -# Generated by Django 5.0.9 on 2024-12-22 13:19 +# Generated by Django 5.0.9 on 2025-01-08 12:32 import django.db.models.deletion import django_minio_backend.models @@ -23,7 +23,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), ('output_content', models.TextField(blank=True, null=True, verbose_name='Исходящий промпт')), - ('starred', models.BooleanField(default=False, verbose_name='Добавлено в избранное')), + ('favourite', models.BooleanField(default=False, verbose_name='В избранном')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Когда создано')), ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_copywrites', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), @@ -63,6 +63,7 @@ class Migration(migrations.Migration): ('order', models.PositiveIntegerField(db_index=True, editable=False, verbose_name='order')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=50, unique=True, verbose_name='Название')), + ('slug', models.SlugField(unique=True, verbose_name='Ярлык')), ('description', models.CharField(blank=True, max_length=200, null=True, verbose_name='Описание')), ('picture', models.FileField(blank=True, null=True, storage=django_minio_backend.models.MinioBackend(bucket_name='air-templates-pictures'), upload_to=tools.copywrite.models.template_picture_uploader, verbose_name='Картинка')), ('content', models.TextField(help_text='Синтаксис в этом поле зависит от Движка шаблонов', verbose_name='Контент')), @@ -83,7 +84,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=100, verbose_name='Название')), ('sysname', models.CharField(max_length=100, verbose_name='Системное название')), ('default_value', models.JSONField(blank=True, null=True, verbose_name='Стандартное значение')), - ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='template_variables', to='copywrite.template', verbose_name='Шаблон')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='template_variables', to='copywrite.template', to_field='slug', verbose_name='Шаблон')), ], options={ 'verbose_name': 'Переменная шаблона', @@ -0,0 +1,23 @@ +# Generated by Django 5.0.9 on 2025-01-08 13:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('copywrite', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='templatecategory', + options={'ordering': ('order',), 'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'}, + ), + migrations.AddField( + model_name='templatecategory', + name='order', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False, verbose_name='order'), + preserve_default=False, + ), + ] @@ -0,0 +1,18 @@ +# Generated by Django 5.0.9 on 2025-01-08 15:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('copywrite', '0002_alter_templatecategory_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='templatecategory', + name='slug', + field=models.CharField(max_length=50, unique=True, verbose_name='Ярлык'), + ), + ] @@ -0,0 +1,19 @@ +# Generated by Django 5.0.9 on 2025-01-08 15:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('copywrite', '0003_alter_templatecategory_slug'), + ] + + operations = [ + migrations.AlterField( + model_name='template', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='templates_categories', to='copywrite.templatecategory', to_field='slug', verbose_name='Категория'), + ), + ] @@ -0,0 +1,22 @@ +# Generated by Django 5.0.9 on 2025-01-08 19:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('copywrite', '0004_alter_template_category'), + ] + + operations = [ + migrations.AlterModelOptions( + name='templatevariable', + options={'ordering': ('order',), 'verbose_name': 'Переменная шаблона', 'verbose_name_plural': 'Переменные шаблонов'}, + ), + migrations.AddField( + model_name='template', + name='hidden', + field=models.BooleanField(default=True, verbose_name='Скрыт'), + ), + ] @@ -1,4 +1,4 @@ -# Generated by Django 5.0.9 on 2024-12-22 16:42 +# Generated by Django 5.0.9 on 2025-01-08 20:39 from django.db import migrations, models @@ -6,17 +6,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('copywrite', '0001_initial'), + ('copywrite', '0005_alter_templatevariable_options_template_hidden'), ] operations = [ - migrations.RemoveField( - model_name='copywrite', - name='starred', - ), migrations.AddField( model_name='copywrite', - name='favourite', - field=models.BooleanField(default=False, verbose_name='В избранном'), + name='deleted', + field=models.BooleanField(default=False, verbose_name='Удален'), ), ] @@ -0,0 +1,18 @@ +# Generated by Django 5.0.9 on 2025-01-08 20:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('copywrite', '0006_copywrite_deleted'), + ] + + operations = [ + migrations.AddField( + model_name='templatevariable', + name='required', + field=models.BooleanField(default=False, verbose_name='Обязательный'), + ), + ] @@ -1,4 +1,4 @@ -from typing import List, Literal +from typing import List from uuid import UUID from channels.routing import URLRouter @@ -10,6 +10,7 @@ from tools.copywrite.consumers import CopywriteConsumer from tools.copywrite.exceptions import CopywriteAlreadyGenerated from tools.copywrite.schemas import ( CopywriteFilterSchema, + CopywriteQuery, CreateDraftSchema, CreateOverridenVariableSchema, OverridenVariableSchema, @@ -26,8 +27,7 @@ from tools.copywrite.schemas import ( ) from tools.copywrite.services.copywrite import CopywriteService from tools.copywrite.services.template import TemplateService - -from .tasks import generate as generate_task +from tools.copywrite.tasks import generate as generate_task router = Router(tags=['copywrite'], auth=SyncAuthBearer()) @@ -48,11 +48,13 @@ ws_router = URLRouter( ) def list_copywrites( request, + ordering: Query[CopywriteQuery], filters: CopywriteFilterSchema = Query(...), - ordering: Literal['favourite'] = 'favourite', ): return CopywriteService.list_copywrites_by_user( - user=request.auth, filters=filters.get_filter_expression(), ordering=ordering + user=request.auth, + filters=filters.get_filter_expression(), + **ordering.model_dump(), ) @@ -82,16 +84,30 @@ def generate(request, id: UUID): generate_task.delay(copywrite_id=id) -@router.put('copywrites/{id}/', tags=['copywrite/copywrites'], response={204: None}) +@router.put( + 'copywrites/{id}/favourite', tags=['copywrite/copywrites'], response={204: None} +) def mark_as_favourite(request, id: UUID): return CopywriteService.mark_as_favourite(copywrite_id=id) +@router.delete( + 'copywrites/{id}/favourite', tags=['copywrite/copywrites'], response={204: None} +) +def remove_from_favourite(request, id: UUID): + return CopywriteService.remove_from_favourite(copywrite_id=id) + + @router.patch('copywrites/{id}/', tags=['copywrite/copywrites'], response={204: None}) def update_input_content(request, id: UUID, data: UpdateSelfCopywriteSchema): return CopywriteService.update_input_content(copywrite_id=id, **data.model_dump()) +@router.delete('copywrites/{id}/', tags=['copywrite/copywrites'], response={204: None}) +def delete_copywrite(request, id: UUID): + return CopywriteService.delete(copywrite_id=id) + + @router.post( 'copywrites/{copywrite_id}/variables/', tags=['copywrite/variables'], @@ -1,4 +1,4 @@ -from typing import Any, Generator, Literal +from typing import Any, Generator, List, Literal from uuid import UUID from django.contrib.contenttypes.models import ContentType @@ -22,15 +22,17 @@ class CopywriteService: cls, user: CustomUserModel, filters: Q = Q(), - ordering: Literal['favourite'] = 'favourite', + sort: List[Literal['favourite', 'created_at', '-favourite', '-created_at']] = [ + '-favourite', + '-created_at', + ], ) -> QuerySet[Copywrite]: return ( Copywrite.objects.filter(user=user) .filter(filters) - .prefetch_related( - 'polymorphic_ctype', - ) - .order_by(ordering) + .filter(deleted=False) + .prefetch_related('polymorphic_ctype') + .order_by(*sort) ) @classmethod @@ -55,6 +57,10 @@ class CopywriteService: def mark_as_favourite(cls, copywrite_id: UUID) -> None: Copywrite.objects.filter(id=copywrite_id).update(favourite=True) + @classmethod + def remove_from_favourite(cls, copywrite_id: UUID): + Copywrite.objects.filter(id=copywrite_id).update(favourite=False) + @classmethod def set_output(cls, copywrite_id: UUID, output_content: str) -> None: Copywrite.objects.filter(id=copywrite_id).update(output_content=output_content) @@ -94,7 +100,7 @@ class CopywriteService: 'polymorphic_ctype' ).get(id=copywrite_id) if isinstance(cp, SelfCopywrite): - content = cp.input_content or '' + content = str(cp.input_content) or '' elif isinstance(cp, TemplateCopywrite): overriden_vars = { overriden_var.variable.id: overriden_var.value @@ -106,8 +112,12 @@ class CopywriteService: for var in cp.template.variables if (active_val := overriden_vars.get(var.id) or var.default_value) } - content = blueprint.render(Context(ctx)) + content = str(blueprint.render(Context(ctx))) else: raise NotImplementedError for chunk in Chatgpt.evaluate(content=content, stream=True): yield chunk + + @classmethod + def delete(cls, copywrite_id: UUID) -> None: + Copywrite.objects.filter(id=copywrite_id).update(deleted=True) @@ -8,7 +8,7 @@ from tools.copywrite.models import Template, TemplateCategory class TemplateService: @classmethod def list_templates(cls, filters: Q = Q()) -> QuerySet[Template]: - return Template.objects.filter(filters) + return Template.objects.filter(filters).filter(hidden=False) @classmethod def get_template_by_id(cls, template_id: UUID) -> Template: @@ -1,10 +1,17 @@ from django.contrib import admin +from import_export.admin import ImportExportMixin +from ordered_model.admin import ( + OrderedInlineModelAdminMixin, + OrderedModelAdmin, + OrderedTabularInline, +) from polymorphic.admin import ( PolymorphicChildModelAdmin, PolymorphicChildModelFilter, PolymorphicParentModelAdmin, ) +from ml_model.inlines import ModelConfigurationInline from tools.copywrite.models import ( Copywrite, OverridenVariable, @@ -14,39 +21,68 @@ from tools.copywrite.models import ( TemplateCopywrite, TemplateVariable, ) +from tools.copywrite.resources import ( + TemplateCategoryResource, + TemplateResource, + TemplateVariableResource, +) -class CopywriteChildAdmin(PolymorphicChildModelAdmin): ... +class CopywriteChildAdmin(PolymorphicChildModelAdmin): + inlines = (ModelConfigurationInline,) @admin.register(SelfCopywrite) -class SelfCopywriteAdmin(CopywriteChildAdmin): ... +class SelfCopywriteAdmin(CopywriteChildAdmin): + inlines = (ModelConfigurationInline,) class OverridenVariableInline(admin.TabularInline): model = OverridenVariable + extra = 0 + can_delete = False + show_change_link = False @admin.register(TemplateCopywrite) class TemplateCopywriteAdmin(CopywriteChildAdmin): - inlines = (OverridenVariableInline,) + inlines = (OverridenVariableInline, ModelConfigurationInline) @admin.register(Copywrite) class CopywriteAdmin(PolymorphicParentModelAdmin): + list_display = ('user', 'created_at', 'favourite', '_draft') child_models = (SelfCopywrite, TemplateCopywrite) list_filter = (PolymorphicChildModelFilter,) + @admin.display(description='Черновик', boolean=True) + def _draft(self, obj: Copywrite): + return obj.draft + @admin.register(TemplateCategory) -class TemplateCategoryAdmin(admin.ModelAdmin): ... +class TemplateCategoryAdmin(ImportExportMixin, OrderedModelAdmin): + list_display = ('title', 'slug', 'move_up_down_links') + resource_classes = (TemplateCategoryResource,) -class TemplateVariableInline(admin.TabularInline): +class TemplateVariableInline(OrderedTabularInline): model = TemplateVariable + fields = ('name', 'sysname', 'default_value', 'move_up_down_links') + readonly_fields = ('move_up_down_links',) + extra = 1 + can_delete = False + show_change_link = False @admin.register(Template) -class TemplateAdmin(admin.ModelAdmin): - list_display = ('id', 'title', 'category') - inlines = (TemplateVariableInline,) +class TemplateAdmin(OrderedInlineModelAdminMixin, ImportExportMixin, OrderedModelAdmin): + list_display = ('id', 'title', 'category', 'hidden', 'move_up_down_links') + inlines = (TemplateVariableInline, ModelConfigurationInline) + list_filter = ('category__title',) + resource_classes = (TemplateResource,) + + +@admin.register(TemplateVariable) +class TemplateVariableAdmin(ImportExportMixin, admin.ModelAdmin): + resource_classes = (TemplateVariableResource,) @@ -1 +1 @@ -COPYWRITE_WS_KEY = 'copywrite_%s' +COPYWRITE_WS_KEY = 'copywrite.%s' @@ -2,6 +2,7 @@ from logging import getLogger from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels_redis.core import RedisChannelLayer +from django.core.cache import cache from tools.copywrite.constants import COPYWRITE_WS_KEY @@ -12,11 +13,14 @@ class CopywriteConsumer(AsyncJsonWebsocketConsumer): channel_layer: RedisChannelLayer async def connect(self): + copywrite_id = f"{self.scope['url_route']['kwargs']['copywrite_id']}" await self.channel_layer.group_add( - COPYWRITE_WS_KEY % f"{self.scope['url_route']['kwargs']['copywrite_id']}", + COPYWRITE_WS_KEY % copywrite_id, self.channel_name, ) - return await self.accept() + await self.accept() + if content := cache.get(COPYWRITE_WS_KEY % copywrite_id): + await self.send_json({'chunk': content, 'end': False}) async def close(self, code=None, reason=None): await self.channel_layer.group_discard( @@ -2,20 +2,23 @@ from typing import Optional from uuid import uuid4 from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import QuerySet from django_minio_backend import MinioBackend from ordered_model.models import OrderedModel from polymorphic.models import PolymorphicManager, PolymorphicModel +from ml_model.models import ModelConfiguration + def template_picture_uploader(instance: 'Template', filename: str): return f'{instance.title}/{filename}' -class TemplateCategory(models.Model): +class TemplateCategory(OrderedModel): title = models.CharField(max_length=50, default='Общее', verbose_name='Название') - slug = models.CharField(max_length=50, verbose_name='Ярлык') + slug = models.CharField(unique=True, max_length=50, verbose_name='Ярлык') def __str__(self) -> str: return self.title @@ -24,7 +27,7 @@ class TemplateCategory(models.Model): def templates(self) -> QuerySet['Template']: return self.templates_categories.all() - class Meta: + class Meta(OrderedModel.Meta): verbose_name = 'Категория' verbose_name_plural = 'Категории' @@ -35,6 +38,7 @@ class Template(OrderedModel): ) title = models.CharField(max_length=50, unique=True, verbose_name='Название') + slug = models.SlugField(unique=True, verbose_name='Ярлык') description = models.CharField( null=True, blank=True, max_length=200, verbose_name='Описание' ) @@ -48,9 +52,18 @@ class Template(OrderedModel): category = models.ForeignKey( TemplateCategory, on_delete=models.PROTECT, + to_field='slug', verbose_name='Категория', related_name='templates_categories', ) + configuration = GenericRelation( + ModelConfiguration, + object_id_field='oid', + content_type_field='ct', + null=True, + blank=True, + ) + hidden = models.BooleanField(default=True, verbose_name='Скрыт') content = models.TextField( verbose_name='Контент', @@ -80,6 +93,7 @@ class TemplateVariable(OrderedModel): template = models.ForeignKey( Template, + to_field='slug', on_delete=models.CASCADE, verbose_name='Шаблон', related_name='template_variables', @@ -87,13 +101,16 @@ class TemplateVariable(OrderedModel): name = models.CharField(max_length=100, verbose_name='Название') sysname = models.CharField(max_length=100, verbose_name='Системное название') + required = models.BooleanField(default=False, verbose_name='Обязательный') default_value = models.JSONField( null=True, blank=True, verbose_name='Стандартное значение' ) + order_with_respect_to = 'template' + class Meta(OrderedModel.Meta): verbose_name = 'Переменная шаблона' - verbose_name_plural = 'Переменные шаблона' + verbose_name_plural = 'Переменные шаблонов' class Copywrite(PolymorphicModel): @@ -113,6 +130,14 @@ class Copywrite(PolymorphicModel): ) favourite = models.BooleanField(default=False, verbose_name='В избранном') created_at = models.DateTimeField(auto_now_add=True, verbose_name='Когда создано') + deleted = models.BooleanField(default=False, verbose_name='Удален') + configuration = GenericRelation( + ModelConfiguration, + object_id_field='oid', + content_type_field='ct', + null=True, + blank=True, + ) objects = PolymorphicManager() @@ -0,0 +1,32 @@ +from import_export import fields as ie_fields +from import_export.resources import ModelResource +from import_export.widgets import ForeignKeyWidget + +from tools.copywrite.models import Template, TemplateCategory, TemplateVariable + + +class TemplateCategoryResource(ModelResource): + class Meta: + model = TemplateCategory + exclude = ('id', 'order') + import_id_fields = ('slug',) + + +class TemplateResource(ModelResource): + category = ie_fields.Field( + column_name='category', + attribute='category', + widget=ForeignKeyWidget(TemplateCategory, 'slug'), + ) + + class Meta: + model = Template + exclude = ('id', 'order', 'picture') + import_id_fields = ('slug', 'category') + + +class TemplateVariableResource(ModelResource): + class Meta: + model = TemplateVariable + exclude = ('id', 'order') + import_id_fields = ('sysname', 'template') @@ -4,6 +4,7 @@ from uuid import UUID from django.db.models import Q from ninja import Field, FilterSchema, ModelSchema, Schema +from ml_model.schemas import ModelConfigurationSchema from tools.copywrite.models import ( OverridenVariable, SelfCopywrite, @@ -30,6 +31,8 @@ class UpdateSelfCopywriteSchema(ModelSchema): 'polymorphic_ctype', 'output_content', 'user', + 'deleted', + 'configuration', ) fields_optional = '__all__' @@ -72,7 +75,14 @@ class TemplateSchema(ModelSchema): class Meta: model = Template - exclude = ('order', 'content', 'category', 'description') + exclude = ( + 'order', + 'content', + 'category', + 'description', + 'configuration', + 'hidden', + ) class TemplateFilterSchema(FilterSchema): @@ -84,17 +94,11 @@ class BaseCopywriteSchema: class SelfCopywriteSchema(BaseCopywriteSchema, ModelSchema): - draft: bool - label: Optional[str] = None + configuration: List[ModelConfigurationSchema] class Meta: model = SelfCopywrite - exclude = ( - 'copywrite_ptr', - 'user', - 'polymorphic_ctype', - 'created_at', - ) + exclude = ('copywrite_ptr', 'user', 'polymorphic_ctype', 'created_at', 'deleted') class SelfCopywriteShortSchema(BaseCopywriteSchema, ModelSchema): @@ -130,17 +134,11 @@ class CreateOverridenVariableSchema(ModelSchema): class TemplateCopywriteSchema(BaseCopywriteSchema, ModelSchema): template: TemplateSchema overriden_variables: List[OverridenVariableSchema] - draft: bool - label: Optional[str] = None + configuration: List[ModelConfigurationSchema] = [] class Meta: model = TemplateCopywrite - exclude = ( - 'copywrite_ptr', - 'user', - 'polymorphic_ctype', - 'created_at', - ) + exclude = ('copywrite_ptr', 'user', 'polymorphic_ctype', 'created_at', 'deleted') class TemplateCopywriteShortSchema(BaseCopywriteSchema, ModelSchema): @@ -174,3 +172,10 @@ class UpdateOverridenVariableSchema(ModelSchema): class Meta: model = OverridenVariable fields = ('value',) + + +class CopywriteQuery(Schema): + sort: List[Literal['favourite', 'created_at', '-favourite', '-created_at']] = [ + '-favourite', + '-created_at', + ] @@ -0,0 +1,13 @@ +from typing import Type + +from django.db.models.signals import pre_save +from django.dispatch import receiver + +from tools.copywrite.models import TemplateCopywrite + + +@receiver(pre_save, sender=TemplateCopywrite) +def copy_model_configuration( + sender: Type[TemplateCopywrite], instance: TemplateCopywrite, **kwargs +): + print(kwargs) @@ -4,6 +4,7 @@ from asgiref.sync import async_to_sync from celery import shared_task from channels.layers import get_channel_layer from channels_redis.core import RedisChannelLayer +from django.core.cache import cache from tools.copywrite.constants import COPYWRITE_WS_KEY from tools.copywrite.services.copywrite import CopywriteService @@ -19,8 +20,10 @@ def generate(copywrite_id: UUID): {'type': 'generate.chunk', 'event_data': {'chunk': chunk, 'end': False}}, ) content += chunk + cache.set(f'copywrite:{copywrite_id}', content) async_to_sync(layer.group_send)( COPYWRITE_WS_KEY % copywrite_id, {'type': 'generate.chunk', 'event_data': {'chunk': '', 'end': True}}, ) CopywriteService.set_output(copywrite_id=copywrite_id, output_content=content) + cache.delete(f'copywrite:{copywrite_id}') @@ -1,4 +1,5 @@ from django.apps import AppConfig +from django.core.signals import setting_changed from django.utils.translation import gettext_lazy as _ @@ -19,6 +20,11 @@ class CopywriteConfig(AppConfig): name = 'tools.copywrite' verbose_name = _('Copywrite') + def ready(self): + from tools.copywrite import signals as copywrite_signals + + setting_changed.connect(copywrite_signals.copy_model_configuration) + class PublicAPIConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' @@ -3,7 +3,7 @@ FROM python:3.12-slim as build WORKDIR /code COPY pyproject.toml poetry.lock /code/ -RUN --mount=type=cache,target=/root/.cache/pip pip install poetry +RUN --mount=type=cache,target=/root/.cache/pip pip install poetry && poetry self add poetry-plugin-export RUN poetry export --only main --output=requirements.txt FROM python:3.12-slim @@ -3,7 +3,7 @@ FROM python:3.12-slim as build WORKDIR /code COPY pyproject.toml poetry.lock /code/ -RUN --mount=type=cache,target=/root/.cache/pip pip install poetry +RUN --mount=type=cache,target=/root/.cache/pip pip install poetry && poetry self add poetry-plugin-export RUN poetry export --with test --with debug --output=requirements.txt @@ -17,6 +17,7 @@ djangorestframework-simplejwt = "^5.2.2" djangorestframework = "^3.14.0" yookassa = "^2.4.0" environs = "^9.5.0" +gunicorn = {extras = ["gevent"], version = "^23.0.0"} dj-rest-auth = "^4.0.1" django-celery-beat = "^2.5.0" minio = "^7.1.15" @@ -56,6 +57,7 @@ httptools = "^0.6.4" uvloop = "^0.21.0" wsproto = "^1.2.0" channels-redis = "^4.2.1" +tiktoken = "<0.6.0" [tool.poetry.group.test.dependencies]