feat(experiments): added experiments business logic

This commit is contained in:
ITQ
2026-02-23 10:54:04 +03:00
parent 68c6ca5267
commit d87671e49a
12 changed files with 2136 additions and 0 deletions
@@ -0,0 +1,95 @@
# Generated by Django 5.2.11 on 2026-02-14 09:55
import django.core.validators
import uuid
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Approval',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('comment', models.TextField(blank=True, verbose_name='comment')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
],
options={
'verbose_name': 'approval',
'verbose_name_plural': 'approvals',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Experiment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200, verbose_name='name')),
('description', models.TextField(blank=True, verbose_name='description')),
('hypothesis', models.TextField(blank=True, verbose_name='hypothesis')),
('status', models.CharField(choices=[('draft', 'Draft'), ('in_review', 'In review'), ('approved', 'Approved'), ('running', 'Running'), ('paused', 'Paused'), ('completed', 'Completed'), ('archived', 'Archived'), ('rejected', 'Rejected')], db_index=True, default='draft', max_length=20, verbose_name='status')),
('version', models.PositiveIntegerField(default=1, verbose_name='version')),
('traffic_allocation', models.DecimalField(decimal_places=2, default=Decimal('100.00'), help_text='Percentage of eligible users included in experiment', max_digits=5, validators=[django.core.validators.MinValueValidator(Decimal('0.01')), django.core.validators.MaxValueValidator(Decimal('100.00'))], verbose_name='traffic allocation %')),
('targeting_rules', models.TextField(blank=True, help_text='DSL expression for targeting', verbose_name='targeting rules')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
],
options={
'verbose_name': 'experiment',
'verbose_name_plural': 'experiments',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ExperimentLog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('log_type', models.CharField(choices=[('status_change', 'Status change'), ('approval', 'Approval'), ('rejection', 'Rejection'), ('review_requested', 'Review requested'), ('guardrail_triggered', 'Guardrail triggered'), ('completed', 'Completed'), ('comment', 'Comment')], max_length=30, verbose_name='log type')),
('comment', models.TextField(blank=True, verbose_name='comment')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='metadata')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
],
options={
'verbose_name': 'experiment log',
'verbose_name_plural': 'experiment logs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ExperimentOutcome',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('outcome', models.CharField(choices=[('rollout', 'Rollout winner'), ('rollback', 'Rollback'), ('no_effect', 'No effect')], max_length=20, verbose_name='outcome')),
('rationale', models.TextField(verbose_name='rationale')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
],
options={
'verbose_name': 'experiment outcome',
'verbose_name_plural': 'experiment outcomes',
},
),
migrations.CreateModel(
name='Variant',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, verbose_name='name')),
('value', models.CharField(help_text='Value returned when this variant is selected', max_length=500, verbose_name='value')),
('weight', models.DecimalField(decimal_places=2, max_digits=5, validators=[django.core.validators.MinValueValidator(Decimal('0.00')), django.core.validators.MaxValueValidator(Decimal('100.00'))], verbose_name='weight %')),
('is_control', models.BooleanField(default=False, verbose_name='is control')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
],
options={
'verbose_name': 'variant',
'verbose_name_plural': 'variants',
'ordering': ['name'],
},
),
]
@@ -0,0 +1,86 @@
# Generated by Django 5.2.11 on 2026-02-14 09:55
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('experiments', '0001_initial'),
('flags', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='approval',
name='approver',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='experiment_approvals', to=settings.AUTH_USER_MODEL, verbose_name='approver'),
),
migrations.AddField(
model_name='experiment',
name='flag',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='experiments', to='flags.featureflag', verbose_name='feature flag'),
),
migrations.AddField(
model_name='experiment',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='owned_experiments', to=settings.AUTH_USER_MODEL, verbose_name='owner'),
),
migrations.AddField(
model_name='experiment',
name='previous_version',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='next_versions', to='experiments.experiment', verbose_name='previous version'),
),
migrations.AddField(
model_name='approval',
name='experiment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='experiments.experiment', verbose_name='experiment'),
),
migrations.AddField(
model_name='experimentlog',
name='experiment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='experiments.experiment', verbose_name='experiment'),
),
migrations.AddField(
model_name='experimentlog',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='experiment_logs', to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='experimentoutcome',
name='decided_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='experiment_decisions', to=settings.AUTH_USER_MODEL, verbose_name='decided by'),
),
migrations.AddField(
model_name='experimentoutcome',
name='experiment',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='outcome', to='experiments.experiment', verbose_name='experiment'),
),
migrations.AddField(
model_name='variant',
name='experiment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='experiments.experiment', verbose_name='experiment'),
),
migrations.AddField(
model_name='experimentoutcome',
name='winning_variant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wins', to='experiments.variant', verbose_name='winning variant'),
),
migrations.AddIndex(
model_name='experiment',
index=models.Index(fields=['flag', 'status'], name='idx_experiment_flag_status'),
),
migrations.AddConstraint(
model_name='experiment',
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ('running', 'paused'))), fields=('flag',), name='unique_active_experiment_per_flag'),
),
migrations.AddConstraint(
model_name='approval',
constraint=models.UniqueConstraint(fields=('experiment', 'approver'), name='unique_approval_per_user'),
),
]