fix(): project refactoring and minor fixes
This commit is contained in:
@@ -12,7 +12,12 @@ from apps.conflicts.models import ExperimentConflictDomain
|
|||||||
from apps.conflicts.services import resolve_domain_conflict
|
from apps.conflicts.services import resolve_domain_conflict
|
||||||
from apps.events.models import Decision
|
from apps.events.models import Decision
|
||||||
from apps.events.services import decision_create
|
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.experiments.selectors import active_experiment_for_flag
|
||||||
from apps.flags.models import FeatureFlag
|
from apps.flags.models import FeatureFlag
|
||||||
from apps.flags.selectors import feature_flag_get_by_key
|
from apps.flags.selectors import feature_flag_get_by_key
|
||||||
@@ -33,7 +38,7 @@ MAX_CONCURRENT_EXPERIMENTS = 3
|
|||||||
COOLDOWN_DAYS = 7
|
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_input = f"{subject_id}:{experiment_id}:{salt}".encode()
|
||||||
hash_bytes = hashlib.sha256(hash_input).digest()
|
hash_bytes = hashlib.sha256(hash_input).digest()
|
||||||
hash_int = int.from_bytes(hash_bytes[:8], byteorder="big")
|
hash_int = int.from_bytes(hash_bytes[:8], byteorder="big")
|
||||||
@@ -112,6 +117,9 @@ def _check_participation_limits(
|
|||||||
subject_id=subject_id,
|
subject_id=subject_id,
|
||||||
reason="experiment_assigned",
|
reason="experiment_assigned",
|
||||||
experiment_id__isnull=False,
|
experiment_id__isnull=False,
|
||||||
|
experiment_id__in=Experiment.objects.filter(
|
||||||
|
status__in=ACTIVE_STATUSES,
|
||||||
|
).values("pk"),
|
||||||
)
|
)
|
||||||
.exclude(experiment_id=experiment_pk)
|
.exclude(experiment_id=experiment_pk)
|
||||||
.values("experiment_id")
|
.values("experiment_id")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from apps.events.selectors import (
|
|||||||
pending_event_exists,
|
pending_event_exists,
|
||||||
pending_events_for_decision,
|
pending_events_for_decision,
|
||||||
)
|
)
|
||||||
|
from config.errors import ConflictError
|
||||||
|
|
||||||
PENDING_TTL_DAYS = 7
|
PENDING_TTL_DAYS = 7
|
||||||
|
|
||||||
@@ -124,20 +125,19 @@ def _process_exposure_event(
|
|||||||
) -> None:
|
) -> None:
|
||||||
decision_id = event_data["decision_id"]
|
decision_id = event_data["decision_id"]
|
||||||
subject_id = event_data["subject_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)
|
decision = decision_get(decision_id)
|
||||||
experiment_id = None
|
|
||||||
variant_id = None
|
|
||||||
if decision:
|
if decision:
|
||||||
experiment_id = decision.experiment_id
|
with suppress(ConflictError):
|
||||||
variant_id = decision.variant_id
|
|
||||||
|
|
||||||
with suppress(IntegrityError):
|
|
||||||
Exposure.objects.create(
|
Exposure.objects.create(
|
||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
experiment_id=experiment_id,
|
experiment_id=decision.experiment_id,
|
||||||
variant_id=variant_id,
|
variant_id=decision.variant_id,
|
||||||
subject_id=subject_id,
|
subject_id=subject_id,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
)
|
)
|
||||||
@@ -158,7 +158,7 @@ def _process_exposure_event(
|
|||||||
def _promote_pending_events(decision_id: str) -> None:
|
def _promote_pending_events(decision_id: str) -> None:
|
||||||
pending = pending_events_for_decision(decision_id)
|
pending = pending_events_for_decision(decision_id)
|
||||||
for pe in pending:
|
for pe in pending:
|
||||||
with suppress(IntegrityError):
|
with suppress(ConflictError):
|
||||||
Event.objects.create(
|
Event.objects.create(
|
||||||
event_id=pe.event_id,
|
event_id=pe.event_id,
|
||||||
event_type=pe.event_type,
|
event_type=pe.event_type,
|
||||||
@@ -178,7 +178,11 @@ def _process_conversion_event(
|
|||||||
) -> None:
|
) -> None:
|
||||||
decision_id = event_data["decision_id"]
|
decision_id = event_data["decision_id"]
|
||||||
subject_id = event_data["subject_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", {})
|
properties = event_data.get("properties", {})
|
||||||
|
|
||||||
if not event_type_obj.requires_exposure or exposure_exists(decision_id):
|
if not event_type_obj.requires_exposure or exposure_exists(decision_id):
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ from apps.notifications.services import (
|
|||||||
notification_enqueue,
|
notification_enqueue,
|
||||||
)
|
)
|
||||||
from apps.reviews.selectors import (
|
from apps.reviews.selectors import (
|
||||||
|
approver_group_get_by_experimenter,
|
||||||
can_user_approve_experimenter,
|
can_user_approve_experimenter,
|
||||||
get_min_approvals_for_experimenter,
|
get_min_approvals_for_experimenter,
|
||||||
|
review_settings_load,
|
||||||
)
|
)
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from config.errors import ForbiddenError
|
from config.errors import ForbiddenError
|
||||||
@@ -130,6 +132,15 @@ def variant_create(
|
|||||||
is_control: bool = False,
|
is_control: bool = False,
|
||||||
) -> Variant:
|
) -> Variant:
|
||||||
ensure_owner_or_admin(experiment, user)
|
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)
|
validate_value_for_type(value, experiment.flag.value_type)
|
||||||
variant = Variant(
|
variant = Variant(
|
||||||
experiment=experiment,
|
experiment=experiment,
|
||||||
@@ -154,6 +165,15 @@ def variant_update(
|
|||||||
is_control: bool | None = None,
|
is_control: bool | None = None,
|
||||||
) -> Variant:
|
) -> Variant:
|
||||||
ensure_owner_or_admin(variant.experiment, user)
|
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:
|
if name is not None:
|
||||||
variant.name = name
|
variant.name = name
|
||||||
if value is not None:
|
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:
|
def _validate_no_active_flag_conflict(experiment: Experiment) -> None:
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ class VariantCrudTest(TestCase):
|
|||||||
|
|
||||||
class SubmitForReviewTest(TestCase):
|
class SubmitForReviewTest(TestCase):
|
||||||
def test_submit_with_valid_variants(self) -> None:
|
def test_submit_with_valid_variants(self) -> None:
|
||||||
|
review_settings_update(
|
||||||
|
default_min_approvals=1, allow_any_approver=True
|
||||||
|
)
|
||||||
exp = make_experiment(suffix="_sr")
|
exp = make_experiment(suffix="_sr")
|
||||||
add_two_variants(exp)
|
add_two_variants(exp)
|
||||||
exp = experiment_submit_for_review(experiment=exp, user=exp.owner)
|
exp = experiment_submit_for_review(experiment=exp, user=exp.owner)
|
||||||
@@ -478,6 +481,9 @@ class LifecycleFlowTest(TestCase):
|
|||||||
class OwnershipPermissionTest(TestCase):
|
class OwnershipPermissionTest(TestCase):
|
||||||
@override
|
@override
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
review_settings_update(
|
||||||
|
default_min_approvals=1, allow_any_approver=True
|
||||||
|
)
|
||||||
self.owner = make_experimenter("_own")
|
self.owner = make_experimenter("_own")
|
||||||
self.other = make_experimenter("_oth")
|
self.other = make_experimenter("_oth")
|
||||||
self.admin = make_admin("_adm")
|
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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -62,7 +62,7 @@ class FeatureFlag(BaseModel):
|
|||||||
RegexValidator(
|
RegexValidator(
|
||||||
regex=FLAG_KEY_PATTERN,
|
regex=FLAG_KEY_PATTERN,
|
||||||
message=(
|
message=(
|
||||||
"Event type name must follow snake_case, "
|
"Flag key must follow snake_case, "
|
||||||
"camelCase, or PascalCase."
|
"camelCase, or PascalCase."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,24 +46,6 @@ def _count_events(
|
|||||||
return qs.count()
|
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(
|
def _average_property(
|
||||||
decision_ids: list[str],
|
decision_ids: list[str],
|
||||||
event_type_name: str,
|
event_type_name: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user