Skip to content

Commit

Permalink
Merge pull request #21 from deligianp/main
Browse files Browse the repository at this point in the history
Support for reproducibility: experiments
  • Loading branch information
deligianp authored Nov 14, 2024
2 parents 0466ebf + 15962a3 commit 7fc66e4
Show file tree
Hide file tree
Showing 18 changed files with 1,763 additions and 1 deletion.
4 changes: 3 additions & 1 deletion schema-api/config/environments/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
'api',
'files',
'graphene_django',
'drf_spectacular'
'drf_spectacular',
'experiments'
]

MIDDLEWARE = [
Expand Down Expand Up @@ -136,6 +137,7 @@
MIGRATION_MODULES = {
'api': 'migrations.api',
'api_auth': 'migrations.api_auth',
'experiments': 'migrations.experiments',
'files': 'migrations.files',
'monitor': 'migrations.monitor',
'quotas': 'migrations.quotas'
Expand Down
Empty file.
3 changes: 3 additions & 0 deletions schema-api/experiments/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions schema-api/experiments/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class ExperimentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'experiments'
24 changes: 24 additions & 0 deletions schema-api/experiments/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models

from api.models import Task, Context
from util.defaults import get_current_datetime


# Create your models here.
class Experiment(models.Model):
name = models.SlugField(max_length=255, blank=False, null=False)
description = models.TextField(blank=True, null=False)
created_at = models.DateTimeField(default=get_current_datetime)
context = models.ForeignKey(Context, on_delete=models.CASCADE)
creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

tasks = models.ManyToManyField(Task, blank=True)

def clean(self):
if self.description is None:
raise ValidationError({'description': 'Description cannot be none'})

class Meta:
unique_together = (('name', 'creator', 'context'),)
28 changes: 28 additions & 0 deletions schema-api/experiments/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from rest_framework import serializers

from experiments.models import Experiment


class ExperimentsListSerializer(serializers.ModelSerializer):
creator = serializers.CharField(source='creator.username', read_only=True)

class Meta:
model = Experiment
fields = ('name', 'created_at', 'creator')
read_only_fields = ('name', 'created_at', 'creator')


class ExperimentSerializer(serializers.ModelSerializer):
creator = serializers.CharField(source='creator.username', read_only=True)

class Meta:
model = Experiment
fields = ('name', 'description', 'created_at', 'creator')
read_only_fields = ('created_at',)


class ExperimentUpdateSerializer(serializers.ModelSerializer):
class Meta:
model = Experiment
fields = ('name', 'description')
extra_kwargs = {'name': {'required': False}}
95 changes: 95 additions & 0 deletions schema-api/experiments/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from lib2to3.fixes.fix_input import context
from typing import Iterable

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.db.models import QuerySet

from api.models import Context, Task
from experiments.models import Experiment
from util.exceptions import ApplicationValidationError, ApplicationDuplicateError, ApplicationNotFoundError, \
ApplicationImplicitPermissionError


class ExperimentService:

def __init__(self, context: Context):
self.context = context

@transaction.atomic
def create_experiment(self, *, name: str, creator: settings.AUTH_USER_MODEL, **optional) -> Experiment:
optional.pop('created_at', None)

experiment = Experiment(**optional)
experiment.context = self.context
experiment.name = name
experiment.creator = creator

return self._validate_and_save(experiment)

def list_experiments(self) -> QuerySet[Experiment]:
return Experiment.objects.filter(context=self.context)

def list_experiments_by_creator(self, creator: settings.AUTH_USER_MODEL) -> QuerySet[Experiment]:
return self.list_experiments().filter(creator=creator)

def retrieve_experiment(self, name: str, creator: settings.AUTH_USER_MODEL) -> Experiment:
try:
return self.list_experiments_by_creator(creator).get(name=name)
except Experiment.DoesNotExist:
raise ApplicationNotFoundError(f'No experiment named "{name}", created by "{creator.username}", exists in '
f'context "{self.context.name}"')

@transaction.atomic
def update_experiment(self, ref_creator: settings.AUTH_USER_MODEL, ref_name: str, **update_values):
update_values.pop('context', None)
update_values.pop('created_at', None)
update_values.pop('creator', None)

experiment = self.retrieve_experiment(ref_name, ref_creator)

for field_name, value in update_values.items():
experiment.__setattr__(field_name, value)

return self._validate_and_save(experiment)

@transaction.atomic
def delete_experiment(self, name: str, creator: settings.AUTH_USER_MODEL):
experiment = self.retrieve_experiment(name, creator)
experiment.delete()

@transaction.atomic
def _validate_and_save(self, experiment: Experiment):
try:
experiment.full_clean(validate_unique=False, validate_constraints=False)
except ValidationError as e:
raise ApplicationValidationError(str(e)) from e

try:
experiment.save()
except IntegrityError as e:
raise ApplicationDuplicateError(str(e)) from e

experiment.refresh_from_db()
return experiment


class ExperimentTaskService:

def __init__(self, experiment: Experiment):
self.experiment = experiment

@transaction.atomic
def set_tasks(self, tasks: Iterable[Task]):
task_set = set(tasks)
context_task_set = set(Task.objects.filter(context=self.experiment.context))
non_context_task_set = task_set.difference(context_task_set)
if len(non_context_task_set) > 0:
raise ApplicationImplicitPermissionError(
f'Task "{non_context_task_set.pop().uuid}" is not a part of the experiment\'s context'
)
self.experiment.tasks.set(tasks)

def get_tasks(self) -> QuerySet[Task]:
return self.experiment.tasks.all()
Empty file.
125 changes: 125 additions & 0 deletions schema-api/experiments/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from datetime import datetime, timezone
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase

from api.models import Context
from experiments.models import Experiment
from util.defaults import get_current_datetime


class ExperimentTestCase(TestCase):
fields = {f.name for f in Experiment._meta.fields if f.concrete}

@classmethod
def setUpTestData(cls):
UserModel = get_user_model()
cls.creator = UserModel(username='creator')
cls.creator.save()
cls.other_creator = UserModel(username='othercreator')
cls.other_creator.save()
cls.context = Context(owner=cls.creator, name='context')
cls.context.save()
cls.other_context = Context(owner=cls.other_creator, name='othercontext')
cls.other_context.save()

def test_save(self):
experiment_data = {
'name': 'experiment',
'description': 'description',
'created_at': get_current_datetime(),
'creator': self.creator,
'context': self.context
}
experiment = Experiment(**experiment_data)
experiment.save()
experiment.refresh_from_db()
self.assertEqual(experiment.name, experiment_data['name'])
self.assertEqual(experiment.description, experiment_data['description'])
self.assertEqual(experiment.created_at, experiment_data['created_at'])
self.assertEqual(experiment.creator, experiment_data['creator'])
self.assertEqual(experiment.context, experiment_data['context'])

def test_name_default_empty_string(self):
experiment = Experiment()
self.assertEqual(experiment.name, '')

def test_name_cannot_be_none(self):
experiment = Experiment(name=None)
with self.assertRaises(ValidationError):
experiment.full_clean(
exclude=self.fields.difference({'name'}), validate_constraints=False, validate_unique=False
)

def test_name_cannot_be_blank(self):
experiment = Experiment(name='')
with self.assertRaises(ValidationError):
experiment.full_clean(
exclude=self.fields.difference({'name'}), validate_constraints=False, validate_unique=False
)

def test_name_is_slug(self):
experiment = Experiment(name='Not a slug')
with self.assertRaises(ValidationError):
experiment.full_clean(
exclude=self.fields.difference({'name'}), validate_constraints=False, validate_unique=False
)

def test_name_is_unique_for_creator_and_context(self):
experiment = Experiment(name='experiment0', creator=self.creator, context=self.context)
experiment.save()

experiment = Experiment(name='experiment0', creator=self.other_creator, context=self.context)
experiment.full_clean(exclude=self.fields.difference({'name', 'creator', 'context'}))

experiment = Experiment(name='experiment0', creator=self.other_creator, context=self.other_context)
experiment.full_clean(exclude=self.fields.difference({'name', 'creator', 'context'}))

experiment = Experiment(name='experiment0', creator=self.creator, context=self.other_context)
experiment.full_clean(exclude=self.fields.difference({'name', 'creator', 'context'}))

experiment = Experiment(name='experiment0', creator=self.creator, context=self.context)
with self.assertRaises(ValidationError):
experiment.full_clean(exclude=self.fields.difference({'name', 'creator', 'context'}))

def test_description_cannot_be_none(self):
experiment = Experiment(description=None)
with self.assertRaises(ValidationError):
experiment.full_clean(
exclude=self.fields.difference({'description'}), validate_constraints=False, validate_unique=False
)

def test_description_default_empty_string(self):
experiment = Experiment()
self.assertEqual(experiment.description, '')

def test_created_at_cannot_be_none(self):
experiment = Experiment(created_at=None)
with self.assertRaises(ValidationError):
experiment.full_clean(
exclude=self.fields.difference({'created_at'}), validate_constraints=False, validate_unique=False
)

def test_created_at_default_current_timestamp(self):
mocked_datetime = datetime(
2020, 1, 1, 1, 1, 1, tzinfo=timezone.utc
)
with patch('django.utils.timezone.now', return_value=mocked_datetime):
experiment = Experiment()
self.assertEqual(experiment.created_at, mocked_datetime)

def test_creator_cannot_be_none(self):
experiment = Experiment(creator=None)
with self.assertRaises(ValidationError):
experiment.full_clean(
exclude=self.fields.difference({'creator'}), validate_constraints=False, validate_unique=False
)

def test_context_cannot_be_none(self):
experiment = Experiment(context=None)
with self.assertRaises(ValidationError):
experiment.full_clean(
exclude=self.fields.difference({'context'}), validate_constraints=False, validate_unique=False
)
Loading

0 comments on commit 7fc66e4

Please sign in to comment.