@@ -23,6 +23,7 @@ from authentication.serializers import ( UserDetailSerializer, ) from payments.models.payment_plan import PaymentPlanUserInfo +from payments.services.payment_plan_service import PaymentPlanService class UserSelector: @@ -53,6 +54,7 @@ class UserSelector: refresh = refresh_token.token access_token = RefreshToken(token=refresh).access_token user.token = {'access': str(access_token), 'refresh': str(refresh)} + user.payment_plan_details.cutoff_at = PaymentPlanService(user).get_cutoff_datetime() return user def list_social_accounts(self, serialize: bool = False): @@ -28,6 +28,7 @@ from ml_model.services.granite import Granite from ml_model.services.grok import Grok from ml_model.services.grok_4_1_fast import Grok_4_1_Fast from ml_model.services.grok_image import Grok_Image +from ml_model.services.grok_image_ultra import Grok_Image_Ultra from ml_model.services.grok_imagine_video import Grok_Imagine_Video from ml_model.services.hailuo import Hailuo from ml_model.services.hunyuan import Hunyuan @@ -49,6 +49,16 @@ class Claude(SerperMixin, StreamSimpleService): 'output': Decimal('7500'), 'coefficient': Decimal('3'), }, # 1M tokens + 'claude-opus-5': { + 'input': Decimal('1500'), + 'output': Decimal('7500'), + 'coefficient': Decimal('3'), + }, # 1M tokens + 'claude-opus-5-fast': { + 'input': Decimal('3000'), + 'output': Decimal('15000'), + 'coefficient': Decimal('3'), + }, # 1M tokens 'claude-fable-5': { 'input': Decimal('3000'), 'output': Decimal('15000'), @@ -1,54 +1,42 @@ +import base64 +import math import time from datetime import timedelta from decimal import Decimal from io import BytesIO from typing import Any +import filetype import requests +from PIL import Image from django.core.files import File +from django.core.files.images import get_image_dimensions from messages.models import Message -from ml_model.models import ( - NeuronModel, -) +from ml_model.exceptions import FileExtensionNotSupported from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector class Flux(SimpleService): - """ - Flux Service - contains abstract method make, which makes a generation - """ - TOKENS_COST = { - 'flux-schnell': { - 'input_imgs': Decimal('3'), - }, + 'input_mp': Decimal('1'), + 'output_mp': Decimal('7.5'), } - OPTIMIZATION_PROMPT = """ - Clean composition with clear subject hierarchy. Soft natural lighting, accurate proportions, and coherent geometry. - Sharp key details with minimal visual noise and artifacts. - """ + _MODEL = 'black-forest-labs/flux-2-klein-9b' - def calculate_price(self, input_message: Message, version: str) -> Decimal: - price_map = self.TOKENS_COST[version] - price = price_map['input_imgs'] - if image_count := input_message.info.get('num_outputs'): - price = price * image_count + def calculate_price(self, input_mp: int, output_mp: int) -> Decimal: + price = self.TOKENS_COST['input_mp'] * input_mp + self.TOKENS_COST['output_mp'] * output_mp return price.quantize(Decimal('0.1'), rounding='ROUND_UP') @classmethod def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: - price = cls.TOKENS_COST['flux-schnell']['input_imgs'] * info.get('num_outputs', 1) - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - - _CALLBACK_BASE = 'black-forest-labs/' - - @property - def neuron_model(self): - return NeuronModel.objects.get(title='Flux') + if file_exists: + return None + return cls.TOKENS_COST['output_mp'].quantize(Decimal('0.1'), rounding='ROUND_UP') def save_results( self, @@ -73,22 +61,43 @@ class Flux(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = 'flux-schnell' - user_prompt = self.translate_prompt(input_message.content) - callback_data = dict( - { - 'prompt': f"{user_prompt}\n{self.OPTIMIZATION_PROMPT}", - 'go_fast': False, - 'output_quality': 100, - **input_message.info, - } - ) - runner = replicate_run( - f'{self._CALLBACK_BASE}{version}', - callback_data, - ) - images = runner if isinstance(runner, list) else [runner] + output_megapixels = 1 + callback_data = { + 'prompt': input_message.content, + 'disable_safety_checker': False, + 'output_format': 'png', + 'output_megapixels': str(output_megapixels), + **input_message.info, + } + input_mp = 0 + if input_message.file: + file_bytes = input_message.file.read() + input_message.file.close() + kind = filetype.guess(file_bytes[:20]) + extension = kind.extension + if extension.upper() not in (extensions := ['JPG', 'JPEG', 'PNG', 'WEBP']): + raise FileExtensionNotSupported(extensions) + format = 'jpeg' if extension not in ('png', 'jpeg', 'webp') else extension + with Image.open(BytesIO(file_bytes)) as source_image: + normalized_image = source_image.convert('RGB') + with BytesIO() as buf: + normalized_image.save(buf, format=format) + file_width, file_height = get_image_dimensions(buf) + input_mp = math.ceil((file_width * file_height) / 1_000_000) + image = f'data:image/{format};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' + normalized_image.close() + callback_data.update({'images': [image]}) + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + predicted := self.calculate_price(input_mp, output_megapixels) + ): + raise InsufficientBalance(balance, predicted) + images = replicate_run(self._MODEL, callback_data) + images = images if isinstance(images, list) else [images] process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice(input_message.content_object.model, input_message=input_message, version=version) + self.handle_invoice( + input_message.content_object.model, + input_mp=input_mp, + output_mp=output_megapixels, + ) msgs = self.save_results(input_message.content, images, process_time, save) return msgs @@ -0,0 +1,94 @@ +import math +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO +from typing import Any + +import filetype +import requests +from django.core.files import File +from django.core.files.images import get_image_dimensions + +from messages.models import Message +from ml_model.exceptions import FileExtensionNotSupported +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Grok_Image_Ultra(SimpleService): + TOKENS_COST = { + '1k': Decimal('25'), + '2k': Decimal('35'), + 'input_mp': Decimal('5'), + } + + _MODEL = 'xai/grok-imagine-image-quality' + + def calculate_price(self, resolution: str, input_mp: int) -> Decimal: + price = self.TOKENS_COST[resolution] + self.TOKENS_COST['input_mp'] * input_mp + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: + if file_exists: + return None + resolution = info.get('resolution', '2k') + if resolution not in ('1k', '2k'): + return None + return cls.TOKENS_COST[resolution].quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results( + self, + content: str, + image_url: str, + time: timedelta, + save: bool = True, + ) -> list[Message]: + message = Message( + content=content, + content_object=self.store, + elapsed_time=time, + file=File(BytesIO(requests.get(image_url).content), '.png'), + ) + if save: + return Message.objects.bulk_create([message]) + return [message] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + resolution = input_message.info.get('resolution', '2k') + callback_data = { + 'prompt': input_message.content, + **input_message.info, + } + input_mp = 0 + if input_message.file: + file_bytes = input_message.file.read() + kind = filetype.guess(file_bytes[:20]) + extension = kind.extension + if extension.upper() not in (extensions := ['JPG', 'JPEG', 'PNG', 'WEBP']): + raise FileExtensionNotSupported(extensions) + file_width, file_height = get_image_dimensions(BytesIO(file_bytes)) + if file_width and file_height: + input_mp = math.ceil((file_width * file_height) / 1_000_000) + else: + input_mp = 1 + callback_data.update({'image': input_message.file.url}) + input_message.file.close() + + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + predicted := self.calculate_price(resolution, input_mp) + ): + raise InsufficientBalance(balance, predicted) + + start_time = time.time() + image_url = replicate_run(self._MODEL, callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice( + input_message.content_object.model, + resolution=resolution, + input_mp=input_mp, + ) + return self.save_results(input_message.content, image_url, process_time, save) @@ -3,9 +3,10 @@ from datetime import timedelta from decimal import Decimal from io import BytesIO from typing import Any -import filetype +import filetype import requests +from PIL import Image from django.core.files import File from messages.models import Message @@ -15,19 +16,18 @@ from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector - class Image_Test_Model(SimpleService): - TOKENS_COST = Decimal('3') - PLACEHOLDER_URL='https://i.pinimg.com/736x/8b/e0/61/8be06158da3986fb4c47497b5660bb29.jpg' + PLACEHOLDER_URL = 'https://i.pinimg.com/736x/8b/e0/61/8be06158da3986fb4c47497b5660bb29.jpg' + SUPPORTED_RATIOS = frozenset({'1:1', '16:9', '9:16', '5:21', '21:5', '3:4', '4:3'}) - def calculate_price(self, num_images: int = 1 ) -> Decimal: + def calculate_price(self, num_images: int = 1) -> Decimal: return self.TOKENS_COST * num_images @classmethod def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: num_images = info.get('num_images', 1) - + return cls.TOKENS_COST * num_images def save_results( @@ -51,40 +51,69 @@ class Image_Test_Model(SimpleService): return Message.objects.bulk_create(messages) return messages - def make(self, input_message: Message, save: bool = True) -> list[Message]: num_images = input_message.info.get('num_images', 1) - - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < (cost := self.TOKENS_COST * num_images): + ratio = input_message.info.get('ratio', '1:1') + + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.TOKENS_COST * num_images + ): raise InsufficientBalance(balance, cost) - + start_time = time.time() ciu = input_message.info.get('ciu') or self.PLACEHOLDER_URL - images = [self._fetch_image(ciu)] * num_images - + raw_image = self._fetch_image(ciu) + resized_image = self._resize_to_ratio(raw_image, ratio) + images = [resized_image] * num_images + process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice(input_message.content_object.model, num_images) - + msgs = self.save_results(input_message.content, process_time, images, save) return msgs - - - def _fetch_image(self, url: str): + def _fetch_image(self, url: str) -> bytes: try: response = requests.get(url, timeout=10) response.raise_for_status() except requests.RequestException: raise InvalidParameterError('Invalid image URL') - + kind = filetype.guess(response.content[:20]) - + if not kind: raise CorruptedFileError - + if not kind.mime.startswith('image/'): raise InvalidParameterError('Image format not supported') - + return response.content - \ No newline at end of file + + def _parse_ratio(self, ratio: str) -> tuple[int, int]: + if ratio not in self.SUPPORTED_RATIOS: + raise InvalidParameterError(f'Unsupported ratio: {ratio}') + rw, rh = map(int, ratio.split(':')) + return rw, rh + + def _resize_to_ratio(self, image_bytes: bytes, ratio: str) -> bytes: + rw, rh = self._parse_ratio(ratio) + + with Image.open(BytesIO(image_bytes)) as img: + img = img.convert('RGB') + src_w, src_h = img.size + target_ratio = rw / rh + src_ratio = src_w / src_h + + if src_ratio > target_ratio: + new_w = int(src_h * target_ratio) + left = (src_w - new_w) // 2 + cropped = img.crop((left, 0, left + new_w, src_h)) + else: + new_h = int(src_w / target_ratio) + top = (src_h - new_h) // 2 + cropped = img.crop((0, top, src_w, top + new_h)) + + buf = BytesIO() + cropped.save(buf, format='PNG') + return buf.getvalue() @@ -0,0 +1,59 @@ +# Generated by makemigration_payment_features on 2026-07-28 15:33 + +import math +from decimal import Decimal + +from django.db import migrations +from django.db.models import Max + + +def add_flux_3_payment_features(apps, schema_editor): + PaymentPlan = apps.get_model('payments', 'PaymentPlan') + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + NeuronModel = apps.get_model('ml_model', 'NeuronModel') + + price = Decimal('9.5') + measurement_unit = 'file' + price_threshold = 0 + model = NeuronModel.objects.get(slug='flux_3') + category = model.category + + max_order_by_plan_id = { + row['plan_id']: row['max_order'] + for row in PaymentPlanFeature.objects.filter(model__category=category) + .values('plan_id') + .annotate(max_order=Max('order')) + } + + features = [] + for plan in PaymentPlan.objects.filter(price__gt=price_threshold): + quantity = math.floor(plan.tokens_per_plan / price) + max_order = max_order_by_plan_id.get(plan.pk) + next_order = (max_order if max_order is not None else -1) + 1 + max_order_by_plan_id[plan.pk] = next_order + features.append( + PaymentPlanFeature( + plan=plan, + model=model, + quantity=quantity, + measurement_unit=measurement_unit, + order=next_order, + ) + ) + PaymentPlanFeature.objects.bulk_create( + features, + update_conflicts=True, + update_fields=['quantity', 'measurement_unit'], + unique_fields=['plan', 'model'], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0031_remove_paymentmethod_attempts_and_more'), + ] + + operations = [ + migrations.RunPython(add_flux_3_payment_features, migrations.RunPython.noop), + ] @@ -0,0 +1,59 @@ +# Generated by makemigration_payment_features on 2026-07-28 15:34 + +import math +from decimal import Decimal + +from django.db import migrations +from django.db.models import Max + + +def add_grok_image_ultra_payment_features(apps, schema_editor): + PaymentPlan = apps.get_model('payments', 'PaymentPlan') + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + NeuronModel = apps.get_model('ml_model', 'NeuronModel') + + price = Decimal('45') + measurement_unit = 'file' + price_threshold = 0 + model = NeuronModel.objects.get(slug='grok_image_ultra') + category = model.category + + max_order_by_plan_id = { + row['plan_id']: row['max_order'] + for row in PaymentPlanFeature.objects.filter(model__category=category) + .values('plan_id') + .annotate(max_order=Max('order')) + } + + features = [] + for plan in PaymentPlan.objects.filter(price__gt=price_threshold): + quantity = math.floor(plan.tokens_per_plan / price) + max_order = max_order_by_plan_id.get(plan.pk) + next_order = (max_order if max_order is not None else -1) + 1 + max_order_by_plan_id[plan.pk] = next_order + features.append( + PaymentPlanFeature( + plan=plan, + model=model, + quantity=quantity, + measurement_unit=measurement_unit, + order=next_order, + ) + ) + PaymentPlanFeature.objects.bulk_create( + features, + update_conflicts=True, + update_fields=['quantity', 'measurement_unit'], + unique_fields=['plan', 'model'], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0032_add_flux_3_payment_features'), + ] + + operations = [ + migrations.RunPython(add_grok_image_ultra_payment_features, migrations.RunPython.noop), + ] @@ -0,0 +1,63 @@ +# Generated by makemigration_payment_features on 2026-07-28 17:27 + +import math +from decimal import Decimal + +from django.db import migrations +from django.db.models import Max + + +def add_flux_payment_features(apps, schema_editor): + PaymentPlan = apps.get_model('payments', 'PaymentPlan') + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + NeuronModel = apps.get_model('ml_model', 'NeuronModel') + + try: + model = NeuronModel.objects.get(slug='flux') + except NeuronModel.DoesNotExist: + return + + price = Decimal('9.5') + measurement_unit = 'file' + price_threshold = 0 + category = model.category + + max_order_by_plan_id = { + row['plan_id']: row['max_order'] + for row in PaymentPlanFeature.objects.filter(model__category=category) + .values('plan_id') + .annotate(max_order=Max('order')) + } + + features = [] + for plan in PaymentPlan.objects.filter(price__gt=price_threshold): + quantity = math.floor(plan.tokens_per_plan / price) + max_order = max_order_by_plan_id.get(plan.pk) + next_order = (max_order if max_order is not None else -1) + 1 + max_order_by_plan_id[plan.pk] = next_order + features.append( + PaymentPlanFeature( + plan=plan, + model=model, + quantity=quantity, + measurement_unit=measurement_unit, + order=next_order, + ) + ) + PaymentPlanFeature.objects.bulk_create( + features, + update_conflicts=True, + update_fields=['quantity', 'measurement_unit'], + unique_fields=['plan', 'model'], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0033_add_grok_image_ultra_payment_features'), + ] + + operations = [ + migrations.RunPython(add_flux_payment_features, migrations.RunPython.noop), + ] @@ -28,6 +28,8 @@ class PaymentMethod(BaseModel): @property def attempts(self): + if hasattr(self, 'total_attempts'): + return self.total_attempts return self.payment_attempts.filter(in_cycle=True).count() def save(self, *args, **kwargs): @@ -64,7 +64,7 @@ class PaymentMethodService: self.user.email, method.uid, cancel_reason, - method.attempts, + method.total_attempts+1, ) return payment_attempt @@ -56,6 +56,25 @@ class PaymentPlanService: next_payment_at=F('next_payment_at') + time_diff ) + def get_cutoff_datetime(self) -> str | None: + pp = self.user.payment_plan_details + method = ( + pp.primary_methods[0] + if hasattr(pp, 'primary_methods') and pp.primary_methods + else pp.primary_method + ) + if not all([pp.is_recurring, pp.next_payment_at, method]): + return None + + attempts = method.attempts + offsets = settings.RECURRING_RETRY_OFFSETS + if not attempts or attempts >= len(offsets): + return None + nxt = offsets[attempts] + cutoff = settings.RECURRING_FULL_ACCESS_CUTOFF_DAY + + return (pp.next_payment_at - timedelta(days=nxt - cutoff)).isoformat() + def has_full_access(self) -> bool: pp = self.user.payment_plan_details plan = pp.plan @@ -133,11 +133,8 @@ class PaymentService: from payments.services.payment_plan_service import PaymentPlanService temporary_cancel_reasons = ( - 'call_issuer', - 'general_decline', 'insufficient_funds', 'internal_timeout', - 'issuer_unavailable', 'payment_method_limit_exceeded', ) payment_method_service = PaymentMethodService(self.user) @@ -175,6 +172,7 @@ class PaymentService: self.user.email, ) else: + payment_method_service.inc_attempts(payment.cancellation_details.reason) deactivated = payment_method_service.deactivate_payment_methods() if deactivated: logger.info( @@ -55,6 +55,7 @@ class UserPlanDetailSchema(Schema): last_payment_at: datetime next_payment_at: datetime | None is_recurring: bool + cutoff_at: str | None class PromoCodeSchema(ModelSchema): @@ -27,7 +27,3 @@ celerybeat-schedule .vscode/ scripts/ - -# Codex local instructions -AGENTS.md -agents.md @@ -0,0 +1,334 @@ +# AGENTS.md + +# Purpose + +You are an AI agent in the role of a **team member** on this repository: you write and edit the Django backend alongside colleagues, follow established conventions, run the needed checks, and stay within the agreed scope. This file is your onboarding guide and the team rules for agents. + +## File versioning + +Version: `0.0.1` +Last updated: 2026-07-21 + +Keep this file current when project rules that agents rely on change (commands, constraints, architecture, paths). Update the version, date, and changelog. Do not edit `AGENTS.md` for routine code-only changes. + +### Changelog + +- 2026-07-21: Initial AGENTS.md + +# About the project + +Django backend of the AIR platform — a neural network marketplace: a unified API for the model catalog and invocation (chatbots, images, video, audio, voice cloning), streaming chats, media generation, authentication (email and social login), token billing, subscription purchases, corporate accounts, and a Public API (OpenAI- and ElevenLabs-compatible). Used by the product frontend, Public API clients, and B2B corporate accounts. Scope: server-side only; access depends on plan/balance and model flags; inference runs through external providers. + +# Scope + +- This file applies to the entire repository. +- An explicit user instruction in chat overrides this file. + +# Key paths + +| What | Path | +|---|---| +| Dependencies | `./pyproject.toml`, lockfile `./uv.lock` | +| Env template | `./.env.dist` | +| Local env (secrets, do not commit) | `./.env` | +| Django settings | `./backend/settings.py` | +| URL routing | `./backend/urls.py` | +| Compose (prod-like + local) | `./docker-compose.yml`, `./docker-compose.local.yml` | +| Ruff config | `./pyproject.toml` → `[tool.ruff]` | +| Application entry point | `./manage.py` | + +Exact package versions: see `pyproject.toml` / `uv.lock` — do not invent versions. + +# Tech stack (runtime) + +- Python 3.12 + Django 5 +- API: Django REST Framework + Django Ninja +- DB: PostgreSQL via Django ORM (`psycopg2`) +- Cache / broker: Redis (Django `RedisCache` + Celery) +- Workers: Celery + django-celery-beat +- Object storage: MinIO (`django-minio-backend`) +- Prod HTTP: Gunicorn → `backend.wsgi:application` +- Authentication: JWT + OAuth2 / social +- Dependency manager: uv +- Lint/format: Ruff +- Payments: YooKassa +- Feature flags: Unleash +- Outbound ML HTTP: mainly httpx; many models via replicate; part of the ChatGPT family via LangChain + +# Project structure + +```text +/ +├── authentication/ # Users, JWT/OAuth/social, B2B hosts/accounts +├── backend/ # settings, urls, WSGI/ASGI, Celery app +├── core/ # Shared base models, admin site +├── lib/ # Middleware, parsers, Unleash, logging +├── locales/ # gettext +├── messages/ # Chat messages +├── ml_model/ # Catalog, provider services/adapters, model billing rules +├── nginx/ # Static nginx helpers +├── payments/ # Plans, tokens, invoices, YooKassa, referrals +├── poller/ # Proxy pool for provider calls +├── reports/ # Error/support reports +├── static/ # Collected static files +├── tools/ +│ ├── chats/ # Chats + SSE streaming +│ ├── copywrite/ # Copywriting templates +│ ├── media/ # Image/video/audio/voice + gallery +│ └── public_api/ # Public API + OpenAI/ElevenLabs-compatible +├── users/ # User settings API +├── manage.py +├── pyproject.toml +├── uv.lock +├── Dockerfile +├── docker-compose.yml +├── docker-compose.local.yml +├── .env.dist +└── .gitlab-ci.yml +``` + +# Architectural principles + +- Domain apps own their models, API, services, tests, errors, permissions, schemas/serializers. +- Business logic → **services**; views/routes stay thin. **Selectors** are legacy: do not create new ones. +- New HTTP endpoints → **Django Ninja** in `/routes/v{N}.py`. Extend DRF only when changing an already existing DRF module. +- A module’s domain exceptions live in its `exceptions` (module/package with **all** errors together). Do **not** make a separate file per exception class. +- For models, services, etc., inherit from existing **Base** classes (`BaseModel`, `BaseService`, and similar) when you actually need what they provide. Do not invent new bases without necessity. +- Stack is **sync-first**: do not write async for creating entities, endpoints, services, etc. If a library’s async function is required and there is no other option — call it via `asgiref.sync.async_to_sync`. +- Shared helpers → `lib/` or `core/` (do not copy across apps). If something must be shared by all modules — extend `lib/` / `core/`, do not duplicate logic in domain apps. +- ORM: use `select_related` / `prefetch_related` / `only`/`defer` and similar as needed. +- New env variables: add to both `.env` and `./.env.dist`. + - **API keys** — store **as-is** in both files; do **not** put fake/placeholder values in `.env.dist`. + - Other variables (not keys) — use meaningful example values, without inventing “secrets” where local stubs like `testtest` are enough. +- Ninja permissions: check inside the view. +- Prefer simple solutions (KISS). Deduplicate via services/mixins (DRY); standalone functions only as a last resort. +- Add variables only when they improve readability. Do not create unnecessary or intermediate variables. + +# Commands + +Run from the **repository root**. `COMPOSE_FILE` must list both compose files (see `./.env.dist`: `COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml`). + +Success for the shell commands below = **exit code 0**, unless noted otherwise. + +## First-time setup + +```bash +cp .env.dist .env +docker compose up --build +``` + +Do **not** run `cp .env.dist .env` if `./.env` already exists and is non-empty (it will overwrite secrets) — ask the user first. + +## Daily run + +```bash +docker compose up +docker compose up --build # after Dockerfile / dependency / lockfile changes +``` + +## Adding a dependency + +```bash +uv add +uv add '==1.2.3' +uv add --group dev +docker compose up --build +``` + +Do not add production dependencies without user agreement (see Constraints). + +## Enter the app container + +```bash +docker compose exec app sh +``` + +Fallback: `docker exec -it main-app-1 sh` (name may vary; prefer `docker compose exec`). + +## Tests + +All tests: + +```bash +docker compose exec app python manage.py test +``` + +Selected apps: + +```bash +docker compose exec app python manage.py test authentication payments tools.chats tools.public_api reports +``` + +Success = exit code 0 and Django reports OK. + +## Django shell (read-only unless the user asked to make changes) + +```bash +docker compose exec app python manage.py shell +``` + +## Lint / format check (before finishing import- or style-sensitive edits) + +```bash +uv run ruff check . +uv run ruff format --check . +``` + +Success = exit code 0. Auto-format only when appropriate: `uv run ruff format .` + +# Code standards + +## Naming + +- Files/modules: `snake_case` — `payment_service.py` +- Classes: `PascalCase` — `PaymentService` +- Functions/variables: `snake_case` — `get_user_balance` +- Constants: `UPPER_SNAKE_CASE` — `MAX_RECURRING_ATTEMPTS` +- Tests: `test_*.py`, methods `test_*` + +## Formatting (Ruff) + +- Config: `./pyproject.toml` → `[tool.ruff]` +- Line length: 109 +- Indent: 4 spaces +- Quotes: single `'` +- Line endings: LF + +## Imports + +- Always place new imports in **alphabetical order** (within their import group/block, as in neighboring code). + +## Typing + +- Type all arguments except `self`, `cls`, `request`, `*args`, `**kwargs` (and similar). +- Avoid `Any` when a precise type exists. +- Complex aliases: in the module’s `typing.py`; shared → `lib/typing.py` or `core/typing.py`. +- Ninja schemas: only `Schema` or `ModelSchema`. + +## Django conventions + +- Ninja handlers in `routes/v{N}.py`: `get_` / `list_` / `delete_` / `update_`. +- Error texts: English source + translation in django.po; **no trailing period**. +- Errors for the frontend return as `{'detail': text_error}` (error text is a string). +- Prefer a model `@property` over long lookup chains at the call site. + +# Workflow + +1. Read related and neighboring files; do not invent your own solution if an analogue already exists. If a neighbor pattern truly solves the task — copy/reuse it. +2. If a change may affect neighboring layers or modules — trace the full path: from the entry (route/view) through services to side effects (signals, tasks, external callbacks); otherwise do not inflate reading. +3. Point fix / narrow feature → do not re-read the whole module. +4. Do not expand scope or refactor neighbors without being asked. +5. Match the style of the current codebase (this file + neighbors). +6. If the user edited files after the agent’s changes — do **not** touch those edits until the user explicitly asks. +7. After import- or format-sensitive edits, run: + +```bash +uv run ruff check . +uv run ruff format --check . +``` + +8. After a change, walk the full path and verify that all dependent links remain consistent and the new scenario works end-to-end. For a narrow local fix — only the affected place. + +# Analysis + +## Full module path + +If the user asked to check the full working path of a module (from the entry point through execution and after it): + +1. Actually walk that path in the code: entry → handlers/services → side effects (signals, tasks, webhooks, external calls) → response/state afterward. +2. Look for illogical scenarios, bugs, regressions, weak/bottleneck spots; consider typical and edge scenarios (including data leaks, critical and odd branches). +3. For the check, rely on **fake** data and reasoning over the code — **without** running scripts/manual runs unless the user explicitly asked otherwise. +4. Answer: a short list **by categories and bullets** of what was found (only what the request is about). + +## Project search + +If the user asked to analyze the project to find something: + +1. Start with semantic search over the codebase. +2. Then targeted `grep` / search by names and strings. +3. If needed, widen the walk across related modules until the needed picture is complete (do not stop at one file if the question is broader). +4. Answer: **by bullets**, concise, in the format and depth the user asked for. + +## Communication with the user + +- Prefer direct, concise answers. Less filler. +- Do not dilute the answer with unnecessary information. +- If clarification is needed — ask briefly and to the point, as a direct question. +- Format replies and code in **Markdown**. Wrap file, directory, function, and class names in backticks: `path/to/file.py`, `ClassName`. +- Optionally for reasoning: `...` (not shown to the user). + +## Final response + +Include: +- briefly what changed; +- if behavior changed — how it works now (briefly); +- optionally a short list of only the **best** alternatives (without clearly worse ones unless the user asked for more analogues). + +Format — Markdown, without extra “wrapping” around the substance. + +## Git: branches, commits, push + +Branch name templates (task id from the tracker): + +```text +fix/ +feature/ +enhancement/ +``` + +Examples: `fix/361`, `feature/205`, `enhancement/288`. + +Commit message: + +```text +refs #: +``` + +`` = number from the branch (`fix/361` → `#361`). Describe what changed in meaning, not only file names. Summary text — **in English**. + +- Commit only when the user asked. +- Push only when the user explicitly asked. + +# Constraints + +## Forbidden (unless the user explicitly allowed that exact action) + +- Delete or rewrite already applied migrations under `*/migrations/` — only add new migration files. +- Run `docker compose down -v` / `docker compose down --volumes` (wipes `pgdata` / `s3data`). +- Run `docker volume prune`, `docker volume rm …`, `docker system prune -a --volumes`. +- Run production/staging deploy (`docker compose build --push`, remote stack/CI deploy). +- Commit `./.env`; hardcode API keys/tokens/passwords in code (keys — only via env / `.env.dist` per the rules above). +- Log secrets, raw tokens, or full payment payloads. +- Overwrite a non-empty `./.env` via `cp .env.dist .env`. +- Edit `.gitlab-ci.yml`, `Dockerfile`, `docker-compose.yml`, `docker-compose.local.yml` without permission. +- Change `uv.lock` other than via agreed `uv add` / `uv remove`. +- Add Django admin for a model unless the user asked for admin. +- Put new logic into `utils` (legacy) +- Create new **selectors**. +- Write async code for creating entities, endpoints, services, etc.; +- Make a separate file per exception class. +- Overwrite or revert user edits made after the agent’s changes. + +## Honesty and accuracy + +- It is forbidden to lie or embellish facts. Information must be accurate and verifiable. +- Do not invent functionality, APIs, code behavior, commands, or results “as if they exist.” +- If after all reasonable analysis attempts (code, neighboring files, in-repo docs, web search when needed) there is still no answer — say you do not know; do not fill the gap with guesses. +- If the task needs up-to-date data (events, tech updates, facts outside the model’s training data) — run a quality real-time web search. Ground the answer in relevant snippets and page URLs; back claims with links and exact information from the sources (do not paraphrase vaguely). + +## Dependencies + +Before `uv add`, agree with the user. Check: does the current stack already cover it? is the package maintained? compatible with Python 3.12 / Django 5.0? no lockfile conflicts? + +Then: + +```bash +uv add +docker compose up --build +``` + +## Security + +- Secrets and API keys only via env; in `./backend/settings.py` read via `env.str` / `env.int` / `env.bool` (environs) — no hardcoding in Python. +- API keys in `.env` and `./.env.dist` — as-is (see Architectural principles); do not commit `./.env` to git. +- Validate input with Ninja `Schema` / `ModelSchema`, or DRF serializers in legacy modules.