fix(): project refactoring and minor fixes

This commit is contained in:
ITQ
2026-02-24 07:56:17 +03:00
parent 41ba6e6714
commit c5ea0b7d5d
7 changed files with 90 additions and 37 deletions
+10 -2
View File
@@ -12,7 +12,12 @@ from apps.conflicts.models import ExperimentConflictDomain
from apps.conflicts.services import resolve_domain_conflict
from apps.events.models import Decision
from apps.events.services import decision_create
from apps.experiments.models import Experiment, ExperimentStatus, Variant
from apps.experiments.models import (
ACTIVE_STATUSES,
Experiment,
ExperimentStatus,
Variant,
)
from apps.experiments.selectors import active_experiment_for_flag
from apps.flags.models import FeatureFlag
from apps.flags.selectors import feature_flag_get_by_key
@@ -33,7 +38,7 @@ MAX_CONCURRENT_EXPERIMENTS = 3
COOLDOWN_DAYS = 7
def _hash_subject(subject_id: str, experiment_id: str, salt: str) -> float:
def _hash_subject(subject_id: str, experiment_id: str, salt: str) -> Decimal:
hash_input = f"{subject_id}:{experiment_id}:{salt}".encode()
hash_bytes = hashlib.sha256(hash_input).digest()
hash_int = int.from_bytes(hash_bytes[:8], byteorder="big")
@@ -112,6 +117,9 @@ def _check_participation_limits(
subject_id=subject_id,
reason="experiment_assigned",
experiment_id__isnull=False,
experiment_id__in=Experiment.objects.filter(
status__in=ACTIVE_STATUSES,
).values("pk"),
)
.exclude(experiment_id=experiment_pk)
.values("experiment_id")
+20 -16
View File
@@ -22,6 +22,7 @@ from apps.events.selectors import (
pending_event_exists,
pending_events_for_decision,
)
from config.errors import ConflictError
PENDING_TTL_DAYS = 7
@@ -124,23 +125,22 @@ def _process_exposure_event(
) -> None:
decision_id = event_data["decision_id"]
subject_id = event_data["subject_id"]
timestamp = parse_datetime(event_data["timestamp"]) or timezone.now()
timestamp = parse_datetime(event_data["timestamp"])
if timestamp is None:
raise ValidationError(
"Field 'timestamp' must be a valid ISO 8601 datetime."
)
decision = decision_get(decision_id)
experiment_id = None
variant_id = None
if decision:
experiment_id = decision.experiment_id
variant_id = decision.variant_id
with suppress(IntegrityError):
Exposure.objects.create(
decision_id=decision_id,
experiment_id=experiment_id,
variant_id=variant_id,
subject_id=subject_id,
timestamp=timestamp,
)
with suppress(ConflictError):
Exposure.objects.create(
decision_id=decision_id,
experiment_id=decision.experiment_id,
variant_id=decision.variant_id,
subject_id=subject_id,
timestamp=timestamp,
)
Event.objects.create(
event_id=event_data["event_id"],
@@ -158,7 +158,7 @@ def _process_exposure_event(
def _promote_pending_events(decision_id: str) -> None:
pending = pending_events_for_decision(decision_id)
for pe in pending:
with suppress(IntegrityError):
with suppress(ConflictError):
Event.objects.create(
event_id=pe.event_id,
event_type=pe.event_type,
@@ -178,7 +178,11 @@ def _process_conversion_event(
) -> None:
decision_id = event_data["decision_id"]
subject_id = event_data["subject_id"]
timestamp = parse_datetime(event_data["timestamp"]) or timezone.now()
timestamp = parse_datetime(event_data["timestamp"])
if timestamp is None:
raise ValidationError(
"Field 'timestamp' must be a valid ISO 8601 datetime."
)
properties = event_data.get("properties", {})
if not event_type_obj.requires_exposure or exposure_exists(decision_id):
+34
View File
@@ -23,8 +23,10 @@ from apps.notifications.services import (
notification_enqueue,
)
from apps.reviews.selectors import (
approver_group_get_by_experimenter,
can_user_approve_experimenter,
get_min_approvals_for_experimenter,
review_settings_load,
)
from apps.users.models import User
from config.errors import ForbiddenError
@@ -130,6 +132,15 @@ def variant_create(
is_control: bool = False,
) -> Variant:
ensure_owner_or_admin(experiment, user)
if experiment.status != ExperimentStatus.DRAFT:
raise ValidationError(
{
"experiment": (
"Variants can only be added while the experiment "
f"is in '{ExperimentStatus.DRAFT}' status."
)
}
)
validate_value_for_type(value, experiment.flag.value_type)
variant = Variant(
experiment=experiment,
@@ -154,6 +165,15 @@ def variant_update(
is_control: bool | None = None,
) -> Variant:
ensure_owner_or_admin(variant.experiment, user)
if variant.experiment.status != ExperimentStatus.DRAFT:
raise ValidationError(
{
"experiment": (
"Variants can only be updated while the experiment "
f"is in '{ExperimentStatus.DRAFT}' status."
)
}
)
if name is not None:
variant.name = name
if value is not None:
@@ -228,6 +248,20 @@ def _validate_experiment_ready_for_review(experiment: Experiment) -> None:
)
}
)
approver_group = approver_group_get_by_experimenter(experiment.owner)
if (
approver_group is None
and not review_settings_load().allow_any_approver
):
raise ValidationError(
{
"approvers": (
"No approvers available for this experiment's owner. "
"Configure an approver group or enable "
"'allow_any_approver'."
)
}
)
def _validate_no_active_flag_conflict(experiment: Experiment) -> None:
@@ -157,6 +157,9 @@ class VariantCrudTest(TestCase):
class SubmitForReviewTest(TestCase):
def test_submit_with_valid_variants(self) -> None:
review_settings_update(
default_min_approvals=1, allow_any_approver=True
)
exp = make_experiment(suffix="_sr")
add_two_variants(exp)
exp = experiment_submit_for_review(experiment=exp, user=exp.owner)
@@ -478,6 +481,9 @@ class LifecycleFlowTest(TestCase):
class OwnershipPermissionTest(TestCase):
@override
def setUp(self) -> None:
review_settings_update(
default_min_approvals=1, allow_any_approver=True
)
self.owner = make_experimenter("_own")
self.other = make_experimenter("_oth")
self.admin = make_admin("_adm")
@@ -0,0 +1,19 @@
# Generated by Django 5.2.11 on 2026-02-24 04:52
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('flags', '0002_alter_featureflag_key'),
]
operations = [
migrations.AlterField(
model_name='featureflag',
name='key',
field=models.CharField(help_text='Unique identifier for the feature flag', max_length=100, unique=True, validators=[django.core.validators.RegexValidator(message='Flag key must follow snake_case, camelCase, or PascalCase.', regex='^[A-Za-z][A-Za-z0-9_]*$')], verbose_name='key'),
),
]
+1 -1
View File
@@ -62,7 +62,7 @@ class FeatureFlag(BaseModel):
RegexValidator(
regex=FLAG_KEY_PATTERN,
message=(
"Event type name must follow snake_case, "
"Flag key must follow snake_case, "
"camelCase, or PascalCase."
),
)
-18
View File
@@ -46,24 +46,6 @@ def _count_events(
return qs.count()
def _count_unique_subjects(
decision_ids: list[str],
event_type_name: str,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> int:
qs = Event.objects.filter(
decision_id__in=decision_ids,
event_type__name=event_type_name,
is_attributed=True,
)
if start_date:
qs = qs.filter(timestamp__gte=start_date)
if end_date:
qs = qs.filter(timestamp__lt=end_date)
return qs.values("subject_id").distinct().count()
def _average_property(
decision_ids: list[str],
event_type_name: str,