-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #21 from deligianp/main
Support for reproducibility: experiments
- Loading branch information
Showing
18 changed files
with
1,763 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from django.contrib import admin | ||
|
||
# Register your models here. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'),) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
Oops, something went wrong.