diff --git a/webapp/apps/comp/asyncsubmit.py b/webapp/apps/comp/asyncsubmit.py index 303e5985..0bda1c9e 100755 --- a/webapp/apps/comp/asyncsubmit.py +++ b/webapp/apps/comp/asyncsubmit.py @@ -8,6 +8,8 @@ from rest_framework import status from rest_framework.response import Response +import paramtools as pt + from webapp.apps.users.models import Project from webapp.apps.comp import actions @@ -40,7 +42,7 @@ def __init__( self.ioutils = ioutils self.compute = compute self.badpost = None - self.meta_parameters = ioutils.displayer.parsed_meta_parameters() + self.meta_parameters = ioutils.model_parameters.meta_parameters_parser() self.sim = sim def submit(self): @@ -63,9 +65,12 @@ def submit(self): parent_sim = None try: - self.valid_meta_params = self.meta_parameters.validate(meta_parameters) + self.meta_parameters.adjust(meta_parameters) + self.valid_meta_params = self.meta_parameters.specification( + meta_data=False, serializable=True + ) errors = None - except ValidationError as ve: + except pt.ValidationError as ve: errors = str(ve) if errors: @@ -73,7 +78,7 @@ def submit(self): parser = self.ioutils.Parser( self.project, - self.ioutils.displayer, + self.ioutils.model_parameters, adjustment, compute=self.compute, **self.valid_meta_params, @@ -88,6 +93,7 @@ def submit(self): job_id=result["job_id"], status="PENDING", parent_sim=self.sim.parent_sim or parent_sim, + model_config=self.ioutils.model_parameters.config, ) # case where parent sim exists and has not yet been assigned if not self.sim.parent_sim and parent_sim: diff --git a/webapp/apps/comp/displayer.py b/webapp/apps/comp/displayer.py deleted file mode 100755 index ff3b40d7..00000000 --- a/webapp/apps/comp/displayer.py +++ /dev/null @@ -1,42 +0,0 @@ -from webapp.apps.comp.compute import SyncCompute, JobFailError -from webapp.apps.comp import actions -from webapp.apps.comp.exceptions import AppError -from webapp.apps.comp.meta_parameters import translate_to_django - - -import os -import json - -INPUTS = os.path.join(os.path.abspath(os.path.dirname(__file__)), "inputs.json") - - -class Displayer: - def __init__(self, project, compute: SyncCompute = None, **meta_parameters): - self.project = project - self.meta_parameters = meta_parameters - self.compute = compute or SyncCompute() - self._cache = {} - - def parsed_meta_parameters(self): - res = self.package_defaults() - return translate_to_django(res["meta_parameters"]) - - def package_defaults(self, cache_result=True): - """ - Get the package defaults from the upstream project. Currently, this is - done by importing the project and calling a function or series of - functions to load the project's inputs data. In the future, this will - be done over the distributed REST API. - """ - args = tuple(v for k, v in sorted(self.meta_parameters.items())) - if args in self._cache: - return self._cache[args] - success, result = self.compute.submit_job( - {"meta_param_dict": self.meta_parameters}, - self.project.worker_ext(action=actions.INPUTS), - ) - if not success: - raise AppError(self.meta_parameters, result["traceback"]) - if cache_result: - self._cache[args] = result - return result diff --git a/webapp/apps/comp/ioutils.py b/webapp/apps/comp/ioutils.py index 6a950951..ac8f9621 100755 --- a/webapp/apps/comp/ioutils.py +++ b/webapp/apps/comp/ioutils.py @@ -1,15 +1,15 @@ from typing import NamedTuple, Type -from webapp.apps.comp.displayer import Displayer +from webapp.apps.comp.model_parameters import ModelParameters from webapp.apps.comp.parser import Parser class IOClasses(NamedTuple): - displayer: Displayer + model_parameters: ModelParameters Parser: Type[Parser] def get_ioutils(project, **kwargs): return IOClasses( - displayer=kwargs.get("Displayer", Displayer)(project), + model_parameters=kwargs.get("ModelParameters", ModelParameters)(project), Parser=kwargs.get("Parser", Parser), ) diff --git a/webapp/apps/comp/meta_parameters.py b/webapp/apps/comp/meta_parameters.py deleted file mode 100755 index d231c752..00000000 --- a/webapp/apps/comp/meta_parameters.py +++ /dev/null @@ -1,95 +0,0 @@ -from dataclasses import dataclass, field -from typing import Dict -from django import forms - -from marshmallow import Schema, fields, validate, exceptions -from paramtools import ValueValidatorSchema - -from webapp.apps.comp.exceptions import ValidationError - - -def coerce_bool(val): - return val in ("True", "true") - - -@dataclass -class MetaParameters: - parameters: Dict[str, "MetaParameter"] = field(default_factory=dict) - - def validate(self, fields: dict, throw_errors: bool = False) -> dict: - validated = {} - errors = {} - if not self.parameters: - return validated - for param_name, param_data in self.parameters.items(): - try: - cleaned = param_data.field.clean( - fields.get(param_name, param_data.value) - ) - except forms.ValidationError as ve: - errors[param_name] = str(ve) - # fall back on default. deal with bad data in full validation. - cleaned = param_data.field.clean(param_data.value) - - validated[param_name] = cleaned - if errors and throw_errors: - raise ValidationError(errors) - return validated - - -@dataclass -class MetaParameter: - title: str - description: str - value: str - field: forms.Field - - -def translate_to_django(meta_parameters: dict) -> MetaParameters: - - # TODO: handle schema - meta_parameters.pop("schema", None) - new_mp = {} - for name, data in meta_parameters.items(): - if data["type"] == "str" and "choice" in data["validators"]: - field = forms.ChoiceField( - label="", - choices=[(c, c) for c in data["validators"]["choice"]["choices"]], - required=False, - ) - elif data["type"] == "str": - field = forms.CharField(label="", required=False) - elif data["type"] in ("int", "float"): - field = forms.IntegerField if data["type"] == "int" else forms.FloatField - if "range" in data["validators"]: - min_value = data["validators"]["range"]["min"] - max_value = data["validators"]["range"]["max"] - field = field( - label="", min_value=min_value, max_value=max_value, required=False - ) - elif "choice" in data["validators"]: - field = forms.TypedChoiceField( - label="", - choices=[(c, c) for c in data["validators"]["choice"]["choices"]], - required=False, - coerce=int if data["type"] == "int" else float, - ) - else: - field = field(label="", required=False) - else: # bool - field = forms.TypedChoiceField( - label="", - coerce=coerce_bool, - choices=list((i, i) for i in (True, False)), - required=False, - ) - if isinstance(data["value"], list): - val = data["value"][0]["value"] - else: - val = data["value"] - # Class used to provide a help message on 'change' events. - field.widget.attrs["class"] = "metaparam-field" - new_mp[name] = MetaParameter( - title=data["title"], description=data["description"], value=val, field=field - ) - return MetaParameters(parameters=new_mp) diff --git a/webapp/apps/comp/migrations/0027_auto_20200406_1547.py b/webapp/apps/comp/migrations/0027_auto_20200406_1547.py new file mode 100755 index 00000000..1ac8b50f --- /dev/null +++ b/webapp/apps/comp/migrations/0027_auto_20200406_1547.py @@ -0,0 +1,78 @@ +# Generated by Django 3.0.3 on 2020-04-06 20:47 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import webapp.apps.comp.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0010_auto_20200319_0854"), + ("comp", "0026_auto_20200221_1228"), + ] + + operations = [ + migrations.CreateModel( + name="ModelConfig", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "inputs_version", + models.CharField(choices=[("v1", "Version 1")], max_length=10), + ), + ( + "model_version", + models.CharField( + blank=True, default=None, max_length=100, null=True + ), + ), + ( + "creation_date", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "meta_parameters_values", + django.contrib.postgres.fields.jsonb.JSONField(null=True), + ), + ("meta_parameters", webapp.apps.comp.models.JSONField(default=dict)), + ("model_parameters", webapp.apps.comp.models.JSONField(default=dict)), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="model_configs", + to="users.Project", + ), + ), + ], + ), + migrations.AddField( + model_name="inputs", + name="model_config", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inputs_instances", + to="comp.ModelConfig", + ), + ), + migrations.AddConstraint( + model_name="modelconfig", + constraint=models.UniqueConstraint( + fields=("project", "model_version", "meta_parameters_values"), + name="unique_model_config", + ), + ), + ] diff --git a/webapp/apps/comp/model_parameters.py b/webapp/apps/comp/model_parameters.py new file mode 100755 index 00000000..efd0b502 --- /dev/null +++ b/webapp/apps/comp/model_parameters.py @@ -0,0 +1,97 @@ +import paramtools as pt + +from webapp.apps.comp.models import ModelConfig +from webapp.apps.comp.compute import SyncCompute, JobFailError +from webapp.apps.comp import actions +from webapp.apps.comp.exceptions import AppError + + +import os +import json + +INPUTS = os.path.join(os.path.abspath(os.path.dirname(__file__)), "inputs.json") + + +def pt_factory(classname, defaults): + return type(classname, (pt.Parameters,), {"defaults": defaults}) + + +class ModelParameters: + """ + Handles logic for getting cached model parameters and updating the cache. + """ + + def __init__(self, project: "Project", compute: SyncCompute = None): + self.project = project + self.compute = compute or SyncCompute() + self.config = None + + def defaults(self, init_meta_parameters=None): + # get Parameters class for meta parameters and adjust its values. + meta_param_parser = self.meta_parameters_parser() + meta_param_parser.adjust(init_meta_parameters or {}) + meta_parameters = meta_param_parser.dump() + return { + "model_parameters": self.model_parameters_parser( + meta_param_parser.specification(meta_data=False, serializable=True) + ), + "meta_parameters": meta_parameters, + } + + def meta_parameters_parser(self): + res = self.get_inputs() + return pt_factory("MetaParametersParser", res["meta_parameters"])() + + def model_parameters_parser(self, meta_parameters_values=None): + res = self.get_inputs(meta_parameters_values) + # TODO: just return defaults or return the parsers, too? + # model_parameters_parser = {} + # for sect, defaults in res["model_parameters"]: + # model_parameters_parser[sect] = type( + # "Parser", (pt.Parameters), {"defaults": defaults}, + # )() + # return model_parameters_parser + return res["model_parameters"] + + def get_inputs(self, meta_parameters_values=None): + """ + Get cached version of inputs or retrieve new version. + """ + meta_parameters_values = meta_parameters_values or {} + + try: + config = ModelConfig.objects.get( + project=self.project, + model_version=self.project.version, + meta_parameters_values=meta_parameters_values, + ) + except ModelConfig.DoesNotExist: + success, result = self.compute.submit_job( + {"meta_param_dict": meta_parameters_values or {}}, + self.project.worker_ext(action=actions.INPUTS), + ) + if not success: + raise AppError(meta_parameters_values, result["traceback"]) + + # clean up meta parameters before saving them. + if meta_parameters_values: + mp = pt_factory("MP", result["meta_parameters"])() + mp.adjust(meta_parameters_values) + save_vals = mp.specification(meta_data=False, serializable=True) + else: + save_vals = {} + + config = ModelConfig.objects.create( + project=self.project, + model_version=self.project.version, + meta_parameters_values=save_vals, + meta_parameters=result["meta_parameters"], + model_parameters=result["model_parameters"], + inputs_version="v1", + ) + + self.config = config + return { + "meta_parameters": config.meta_parameters, + "model_parameters": config.model_parameters, + } diff --git a/webapp/apps/comp/models.py b/webapp/apps/comp/models.py index dcdac60e..561c5f39 100755 --- a/webapp/apps/comp/models.py +++ b/webapp/apps/comp/models.py @@ -7,14 +7,13 @@ from dataclasses import dataclass, field from typing import List, Union -from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db import IntegrityError, transaction from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.functional import cached_property from django.utils import timezone -from django.contrib.postgres.fields import JSONField +from django.contrib.postgres.fields import JSONField as JSONBField from django.contrib.auth.models import Group from django.urls import reverse from django.utils import timezone @@ -41,22 +40,74 @@ ) +class JSONField(JSONBField): + def db_type(self, connection): + return "json" + + +class ModelConfigManager(models.Manager): + def get(self, project, model_version, meta_parameters_values, **kwargs): + if meta_parameters_values: + mp_search_kwargs = { + f"meta_parameters_values__{name}": val + for name, val in meta_parameters_values.items() + } + else: + mp_search_kwargs = {"meta_parameters_values": {}} + kwargs.update(mp_search_kwargs) + return super().get(model_version=model_version, project=project, **kwargs) + + +class ModelConfig(models.Model): + project = models.ForeignKey( + "users.Project", + on_delete=models.SET_NULL, + related_name="model_configs", + null=True, + ) + inputs_version = models.CharField(choices=(("v1", "Version 1"),), max_length=10) + model_version = models.CharField( + blank=True, default=None, null=True, max_length=100 + ) + creation_date = models.DateTimeField(default=timezone.now) + + meta_parameters_values = JSONBField(null=True) + meta_parameters = JSONField(default=dict) + model_parameters = JSONField(default=dict) + + objects = ModelConfigManager() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["project", "model_version", "meta_parameters_values"], + name="unique_model_config", + ) + ] + + class Inputs(models.Model): parent_sim = models.ForeignKey( "Simulation", null=True, related_name="child_inputs", on_delete=models.SET_NULL ) - meta_parameters = JSONField(default=None, blank=True, null=True) - raw_gui_inputs = JSONField(default=None, blank=True, null=True) - gui_inputs = JSONField(default=None, blank=True, null=True) + model_config = models.ForeignKey( + ModelConfig, + on_delete=models.SET_NULL, + null=True, + related_name="inputs_instances", + ) + meta_parameters = JSONBField(default=None, blank=True, null=True) + raw_gui_inputs = JSONBField(default=None, blank=True, null=True) + gui_inputs = JSONBField(default=None, blank=True, null=True) # Validated GUI input that has been parsed to have the correct data types, # or JSON reform uploaded as file - custom_adjustment = JSONField(default=dict, blank=True, null=True) + custom_adjustment = JSONBField(default=dict, blank=True, null=True) - errors_warnings = JSONField(default=None, blank=True, null=True) + errors_warnings = JSONBField(default=None, blank=True, null=True) # The parameters that will be used to run the model - adjustment = JSONField(default=dict, blank=True, null=True) + adjustment = JSONBField(default=dict, blank=True, null=True) # If project changes input type, we still want to know the type of the # previous model runs' inputs. @@ -230,7 +281,6 @@ def fork(self, sim, user): "Simulations may not be forked while they are in a pending state. " "Please try again once the simulation has completed." ) - inputs = Inputs.objects.create( owner=user.profile, project=sim.project, @@ -294,15 +344,15 @@ class Simulation(models.Model): # TODO: dimension needs to go dimension_name = "Dimension--needs to go" title = models.CharField(default="Untitled Simulation", max_length=500) - readme = JSONField(null=True, default=None, blank=True) + readme = JSONBField(null=True, default=None, blank=True) last_modified = models.DateTimeField(default=timezone.now) parent_sim = models.ForeignKey( "self", null=True, related_name="child_sims", on_delete=models.SET_NULL ) inputs = models.OneToOneField(Inputs, on_delete=models.CASCADE, related_name="sim") - meta_data = JSONField(default=None, blank=True, null=True) - outputs = JSONField(default=None, blank=True, null=True) - aggr_outputs = JSONField(default=None, blank=True, null=True) + meta_data = JSONBField(default=None, blank=True, null=True) + outputs = JSONBField(default=None, blank=True, null=True) + aggr_outputs = JSONBField(default=None, blank=True, null=True) traceback = models.CharField(null=True, blank=True, default=None, max_length=8000) owner = models.ForeignKey( "users.Profile", on_delete=models.CASCADE, null=True, related_name="sims" diff --git a/webapp/apps/comp/parser.py b/webapp/apps/comp/parser.py index 70038a91..1ef71472 100755 --- a/webapp/apps/comp/parser.py +++ b/webapp/apps/comp/parser.py @@ -3,7 +3,6 @@ from webapp.apps.comp import actions from webapp.apps.comp.compute import Compute -from webapp.apps.comp.displayer import Displayer from webapp.apps.comp.exceptions import AppError from webapp.apps.comp.models import Inputs @@ -16,7 +15,7 @@ class ParameterLookUpException(Exception): class BaseParser: def __init__( - self, project, displayer, clean_inputs, compute=None, **valid_meta_params + self, project, model_parameters, clean_inputs, compute=None, **valid_meta_params ): self.project = project self.clean_inputs = clean_inputs @@ -24,7 +23,7 @@ def __init__( self.valid_meta_params = valid_meta_params for param, value in valid_meta_params.items(): setattr(self, param, value) - defaults = displayer.package_defaults() + defaults = model_parameters.defaults(self.valid_meta_params) self.grouped_defaults = defaults["model_parameters"] self.flat_defaults = { k: v for _, sect in self.grouped_defaults.items() for k, v in sect.items() diff --git a/webapp/apps/comp/serializers.py b/webapp/apps/comp/serializers.py index ab6260ad..86bcbbbc 100755 --- a/webapp/apps/comp/serializers.py +++ b/webapp/apps/comp/serializers.py @@ -4,7 +4,7 @@ from webapp.apps.publish.serializers import PublishSerializer from .exceptions import ResourceLimitException -from .models import Inputs, Simulation, PendingPermission +from .models import Inputs, Simulation, PendingPermission, ModelConfig class OutputsSerializer(serializers.Serializer): @@ -102,6 +102,30 @@ class Meta: ) +class ModelConfigSerializer(serializers.ModelSerializer): + project = serializers.StringRelatedField() + + class Meta: + model = ModelConfig + fields = ( + "project", + "model_version", + "meta_parameters_values", + "meta_parameters", + "model_parameters", + "creation_date", + ) + + read_only = ( + "project", + "model_version", + "meta_parameters_values", + "meta_parameters", + "model_parameters", + "creation_date", + ) + + class InputsSerializer(serializers.ModelSerializer): """ Serializer for the Inputs object. @@ -133,6 +157,8 @@ class InputsSerializer(serializers.ModelSerializer): # notification status on sims created from /[owner]/[title]/api/v1/ notify_on_completion = serializers.BooleanField(required=False) + model_config = ModelConfigSerializer(allow_null=True, required=False) + # see to_representation # role = serializers.BooleanField(source="role") @@ -157,6 +183,7 @@ class Meta: "gui_url", "job_id", "meta_parameters", + "model_config", "notify_on_completion", "parent_model_pk", "role", diff --git a/webapp/apps/comp/tests/test_api_parser.py b/webapp/apps/comp/tests/test_api_parser.py index 52ff3796..f1d1861b 100755 --- a/webapp/apps/comp/tests/test_api_parser.py +++ b/webapp/apps/comp/tests/test_api_parser.py @@ -1,25 +1,25 @@ from webapp.apps.users.models import Project -from webapp.apps.comp.displayer import Displayer +from webapp.apps.comp.model_parameters import ModelParameters from webapp.apps.comp.ioutils import get_ioutils from webapp.apps.comp.models import Inputs from webapp.apps.comp.tests.parser import LocalAPIParser def test_api_parser(db, get_inputs, valid_meta_params): - class MockDisplayer(Displayer): - def package_defaults(self): + class MockMp(ModelParameters): + def get_inputs(self, meta_parameters=None): return get_inputs project = Project.objects.get(title="Used-for-testing") - ioutils = get_ioutils(project, Displayer=MockDisplayer, Parser=LocalAPIParser) - ioutils.displayer.meta_parameters = valid_meta_params + ioutils = get_ioutils(project, ModelParameters=MockMp, Parser=LocalAPIParser) + ioutils.model_parameters.init_meta_parameters = valid_meta_params clean_inputs = { "majorsection1": {"intparam": 3, "boolparam": True}, "majorsection2": {"mj2param": 4}, } parser = LocalAPIParser( - project, ioutils.displayer, clean_inputs, **valid_meta_params + project, ioutils.model_parameters, clean_inputs, **valid_meta_params ) res = parser.parse_parameters() adjustment = res["adjustment"] @@ -36,20 +36,20 @@ def package_defaults(self): def test_api_parser_extra_section(db, get_inputs, valid_meta_params): - class MockDisplayer(Displayer): - def package_defaults(self): + class MockMp(ModelParameters): + def get_inputs(self, meta_parameters=None): return get_inputs project = Project.objects.get(title="Used-for-testing") - ioutils = get_ioutils(project, Displayer=MockDisplayer, Parser=LocalAPIParser) - ioutils.displayer.meta_parameters = valid_meta_params + ioutils = get_ioutils(project, ModelParameters=MockMp, Parser=LocalAPIParser) + ioutils.model_parameters.init_meta_parameters = valid_meta_params clean_inputs = { "majorsection1-mispelled": {"intparam": 3, "boolparam": True}, "majorsection2": {"mj2param": 4}, } parser = LocalAPIParser( - project, ioutils.displayer, clean_inputs, **valid_meta_params + project, ioutils.model_parameters, clean_inputs, **valid_meta_params ) res = parser.parse_parameters() adjustment = res["adjustment"] diff --git a/webapp/apps/comp/tests/test_asyncviews.py b/webapp/apps/comp/tests/test_asyncviews.py index f897fdc8..5be71085 100755 --- a/webapp/apps/comp/tests/test_asyncviews.py +++ b/webapp/apps/comp/tests/test_asyncviews.py @@ -374,7 +374,7 @@ def test_get_inputs(self, api_client, worker_url): assert resp.status_code == 200 ioutils = get_ioutils(self.project) - exp = ioutils.displayer.package_defaults() + exp = ioutils.model_parameters.defaults() assert exp == resp.data @pytest.mark.parametrize("use_api", [True, False]) @@ -461,6 +461,12 @@ def test_post_inputs(self, api_client, worker_url): meta_params = {"meta_parameters": self.inputs_ok()["meta_parameters"]} with requests_mock.Mocker() as mock: print("mocking", f"{worker_url}{self.owner}/{self.title}/inputs") + mock.register_uri( + "POST", + f"{worker_url}{self.owner}/{self.title}/version", + text=json.dumps({"status": "SUCCESS", "version": "1.0.0"}), + ) + mock.register_uri( "POST", f"{worker_url}{self.owner}/{self.title}/inputs", @@ -474,8 +480,7 @@ def test_post_inputs(self, api_client, worker_url): assert resp.status_code == 200 ioutils = get_ioutils(self.project) - ioutils.displayer.meta_parameters.update(meta_params["meta_parameters"]) - exp = ioutils.displayer.package_defaults() + exp = ioutils.model_parameters.defaults(meta_params["meta_parameters"]) assert exp == resp.data @pytest.mark.parametrize("test_lower", [False, True]) diff --git a/webapp/apps/comp/tests/test_meta_parameters.py b/webapp/apps/comp/tests/test_meta_parameters.py deleted file mode 100755 index 21dbed92..00000000 --- a/webapp/apps/comp/tests/test_meta_parameters.py +++ /dev/null @@ -1,63 +0,0 @@ -from django import forms - -from webapp.apps.comp.meta_parameters import ( - coerce_bool, - MetaParameter, - MetaParameters, - translate_to_django, -) - - -def test_meta_parameters_instance(): - meta_parameters = MetaParameters() - assert meta_parameters - assert meta_parameters.parameters == {} - - -def test_meta_parameters(): - meta_parameters = MetaParameters( - parameters={ - "inttest": MetaParameter( - title="Int test", - description="An int test", - value=1, - field=forms.IntegerField(), - ), - "booltest": MetaParameter( - description="a bool test", - title="A bool test", - value=True, - field=forms.BooleanField(required=False), - ), - } - ) - valid = meta_parameters.validate({"inttest": "2", "booltest": False}) - assert valid["inttest"] == 2 - assert valid["booltest"] is False - valid = meta_parameters.validate({"booltest": False}) - assert valid["inttest"] == 1 - assert valid["booltest"] is False - - -def test_translate(): - metaparameters = { - "use_full_data": { - "title": "Use full data", - "description": "use full data...", - "value": True, - "type": "bool", - "validators": {}, - } - } - result = translate_to_django(metaparameters) - - mp = next(v for v in result.parameters.values()) - assert mp.title == "Use full data" - assert next(k for k in result.parameters.keys()) == "use_full_data" - assert mp.value == True - assert isinstance(mp.field, forms.TypedChoiceField) - - -def test_coerce_bool(): - assert coerce_bool("True") is True - assert coerce_bool("False") is False diff --git a/webapp/apps/comp/tests/test_model_parameters.py b/webapp/apps/comp/tests/test_model_parameters.py new file mode 100755 index 00000000..1324b419 --- /dev/null +++ b/webapp/apps/comp/tests/test_model_parameters.py @@ -0,0 +1,157 @@ +import copy + +import pytest +import requests_mock +import paramtools as pt + +from webapp.apps.users.models import Project, Profile + +from webapp.apps.comp.model_parameters import ModelParameters +from webapp.apps.comp.models import ModelConfig + + +class MetaParams(pt.Parameters): + d0: int + d1: str + defaults = { + "d0": {"title": "d0", "description": "", "type": "int", "value": 1}, + "d1": {"title": "d1", "description": "", "type": "str", "value": "hello"}, + } + + +class Params(pt.Parameters): + defaults = { + "schema": { + "labels": { + "d0": {"type": "int", "validators": {"range": {"min": 0, "max": 3}}}, + "d1": { + "type": "str", + "validators": {"choice": {"choices": ["hello", "world"]}}, + }, + } + }, + "param": { + "title": "param", + "description": "test", + "type": "int", + "value": [ + {"d0": 1, "d1": "hello", "value": 1}, + {"d0": 1, "d1": "world", "value": 1}, + {"d0": 2, "d1": "hello", "value": 1}, + {"d0": 3, "d1": "world", "value": 1}, + ], + }, + } + + +def get_inputs_callback(request, context): + metaparams = MetaParams(array_first=True) + metaparams.adjust(request.json()["meta_param_dict"]) + params = Params() + params.set_state(d0=metaparams.d0.tolist(), d1=metaparams.d1) + return { + "status": "SUCCESS", + "meta_parameters": metaparams.dump(), + "model_parameters": {"section": params.dump()}, + } + + +@pytest.fixture +def mock_project(db, worker_url): + profile = Profile.objects.get(user__username="modeler") + project = Project.objects.create( + owner=profile, + title="test", + status="live", + description="", + oneliner="oneliner", + repo_url="https://repo.com/test", + server_size=["8,2"], + exp_task_time=10, + server_cost=0.1, + listed=True, + sponsor=profile, + ) + with requests_mock.Mocker() as mock: + mock.register_uri( + "POST", + f"{worker_url}{project.owner}/{project.title}/inputs", + json=get_inputs_callback, + status_code=200, + ) + mock.register_uri( + "POST", + f"{worker_url}{project.owner}/{project.title}/version", + json={"status": "SUCCESS", "version": "v1"}, + status_code=200, + ) + + yield project + + +def test_model_parameters(mock_project): + project = mock_project + + # test get parameters without any meta parameters specified. + mp = ModelParameters(project) + assert ModelConfig.objects.filter(project=project).count() == 0 + assert mp.get_inputs() + assert ModelConfig.objects.filter(project=project).count() == 1 + + mp.meta_parameters_parser() + assert ModelConfig.objects.filter(project=project).count() == 1 + mp.model_parameters_parser() + assert ModelConfig.objects.filter(project=project).count() == 1 + + mc = ModelConfig.objects.get( + project=project, model_version="v1", meta_parameters_values={} + ) + defaults = mp.defaults() + assert mc.meta_parameters_values == {} + assert mc.meta_parameters == mp.meta_parameters_parser().dump() + assert mc.model_parameters == mp.model_parameters_parser({}) + assert { + "model_parameters": mc.model_parameters, + "meta_parameters": mc.meta_parameters, + } == defaults + + # test get cached values with updates to meta parameters + mp_values_cleaned = {"d0": [{"value": 1}], "d1": [{"value": "hello"}]} + mp = ModelParameters(project) + defaults = mp.defaults(mp_values_cleaned) + assert ModelConfig.objects.filter(project=project).count() == 2 + + mc = ModelConfig.objects.get( + project=project, model_version="v1", meta_parameters_values=mp_values_cleaned + ) + assert mc.meta_parameters_values == mp_values_cleaned + assert mc.meta_parameters == mp.meta_parameters_parser().dump() + assert mc.model_parameters == mp.model_parameters_parser(mp_values_cleaned) + assert { + "model_parameters": mc.model_parameters, + "meta_parameters": mc.meta_parameters, + } == defaults + + # test going back to init doesn't break cache + defaults = mp.defaults() + assert ModelConfig.objects.filter(project=project).count() == 2 + + +def test_parameter_order(monkeypatch, mock_project): + project = mock_project + new_defaults = copy.deepcopy(Params.defaults) + for i in range(50): + new_defaults[f"param-{i}"] = copy.deepcopy(new_defaults["param"]) + + monkeypatch.setattr(Params, "defaults", new_defaults) + + mp = ModelParameters(project=mock_project) + mp.get_inputs() + + mc = ModelConfig.objects.get( + project=project, model_version="v1", meta_parameters_values={} + ) + + params = Params() + for act, exp in zip(mc.model_parameters["section"], params.dump()): + assert act == exp, f"Expected {act} === {exp}" diff --git a/webapp/apps/comp/tests/utils.py b/webapp/apps/comp/tests/utils.py index 835aee63..ad9d3d3a 100644 --- a/webapp/apps/comp/tests/utils.py +++ b/webapp/apps/comp/tests/utils.py @@ -5,7 +5,7 @@ from webapp.apps.users.models import Project, Profile, create_profile_from_user from webapp.apps.comp.asyncsubmit import SubmitInputs, SubmitSim -from webapp.apps.comp.displayer import Displayer +from webapp.apps.comp.model_parameters import ModelParameters from webapp.apps.comp.ioutils import get_ioutils from webapp.apps.comp.models import Simulation from webapp.apps.comp.parser import APIParser @@ -31,12 +31,12 @@ def _submit_inputs( parent_model_pk=None, notify_on_completion=None, ): - class MockDisplayer(Displayer): - def package_defaults(self): + class MockMP(ModelParameters): + def get_inputs(self, meta_parameters=None): return get_inputs project = Project.objects.get(title=title) - ioutils = get_ioutils(project, Displayer=MockDisplayer, Parser=APIParser) + ioutils = get_ioutils(project, ModelParameters=MockMP, Parser=APIParser) factory = APIRequestFactory() data = { diff --git a/webapp/apps/comp/views/api.py b/webapp/apps/comp/views/api.py index a8c77af8..aeedf2ea 100755 --- a/webapp/apps/comp/views/api.py +++ b/webapp/apps/comp/views/api.py @@ -17,6 +17,7 @@ from rest_framework.fields import IntegerField from rest_framework import filters +import paramtools as pt import cs_storage from webapp.apps.users.models import Project, Profile @@ -64,15 +65,10 @@ def get_inputs(self, kwargs, meta_parameters=None): title__iexact=kwargs["title"], ) ioutils = get_ioutils(project) - if meta_parameters is not None: - try: - parsed_mp = ioutils.displayer.parsed_meta_parameters() - ioutils.displayer.meta_parameters = parsed_mp.validate( - meta_parameters, throw_errors=True - ) - except ValidationError as e: - return Response(str(e), status=status.HTTP_400_BAD_REQUEST) - defaults = ioutils.displayer.package_defaults() + try: + defaults = ioutils.model_parameters.defaults(meta_parameters) + except pt.ValidationError as e: + return Response(str(e), status=status.HTTP_400_BAD_REQUEST) if "year" in defaults["meta_parameters"]: defaults.update({"extend": True}) return Response(defaults) diff --git a/webapp/apps/conftest.py b/webapp/apps/conftest.py index c810047e..b86e31e0 100755 --- a/webapp/apps/conftest.py +++ b/webapp/apps/conftest.py @@ -6,7 +6,7 @@ import requests import pytest import stripe -import paramtools +import paramtools as pt from django import forms from django.core.management import call_command @@ -25,7 +25,7 @@ create_pro_billing_objects, ) from webapp.apps.users.models import Profile, Project -from webapp.apps.comp.meta_parameters import translate_to_django +from webapp.apps.comp.model_parameters import ModelParameters from webapp.apps.comp.models import Inputs, Simulation @@ -340,12 +340,15 @@ def meta_param_dict(comp_inputs_json): @pytest.fixture def meta_param(meta_param_dict): - return translate_to_django(meta_param_dict) + class metaparams(pt.Parameters): + defaults = meta_param_dict + + return metaparams() @pytest.fixture -def valid_meta_params(meta_param): - return meta_param.validate({}) +def valid_meta_params(meta_param, meta_param_dict): + return meta_param.specification(meta_data=False, serializable=True) @pytest.fixture @@ -359,13 +362,13 @@ def get_inputs(comp_inputs_json): } } - class Params1(paramtools.Parameters): + class Params1(pt.Parameters): defaults = dict(comp_inputs_json["model_params"]["majorsection1"], **schema) - class Params2(paramtools.Parameters): + class Params2(pt.Parameters): defaults = dict(comp_inputs_json["model_params"]["majorsection2"], **schema) - class MetaParams(paramtools.Parameters): + class MetaParams(pt.Parameters): array_first = True defaults = comp_inputs_json["meta_param_dict"] diff --git a/webapp/apps/publish/tests/test_views.py b/webapp/apps/publish/tests/test_views.py index 4c3fac57..0ea711b5 100755 --- a/webapp/apps/publish/tests/test_views.py +++ b/webapp/apps/publish/tests/test_views.py @@ -108,7 +108,7 @@ def test_put_detail_api(self, client, test_models, profile, password): title="Used-for-testing", owner__user__username="modeler" ) assert project.description == put_data["description"] - assert project.status == "updating" + assert project.status == "live" # Description can't be empty. resp = client.put( diff --git a/webapp/apps/users/models.py b/webapp/apps/users/models.py index 08710f97..5f2cf360 100755 --- a/webapp/apps/users/models.py +++ b/webapp/apps/users/models.py @@ -252,6 +252,7 @@ def sim_count(self): def user_count(self): return self.sims.distinct("owner__user").count() + @cached_property def version(self): if self.status not in ("updating", "live"): return None