diff --git a/apps/fixtures/apps_fixtures.json b/apps/fixtures/apps_fixtures.json index 2068454cb..472485850 100644 --- a/apps/fixtures/apps_fixtures.json +++ b/apps/fixtures/apps_fixtures.json @@ -1,146 +1,194 @@ [ { - "model": "apps.apps", - "pk": 5, - "fields": { - "name": "Mongo Express", - "slug": "mongo-express", - "category": "store", - "table_field": { + "model": "apps.apps", + "pk": 5, + "fields": { + "name": "Mongo Express", + "slug": "mongo-express", + "category": "store", + "table_field": { "url": "https://{{ release }}.{{ global.domain }}" - }, - "description": "", - "settings": { - "apps":{ - "MongoDB":"one" }, - "default_values": { + "description": "", + "settings": { + "apps": { + "MongoDB": "one" + }, + "default_values": { "port": "80", "targetport": "8081" }, - "permissions": { - "public": {"value":"false", "option": "false"}, - "project": {"value":"true", "option": "true"}, - "private": {"value":"false", "option": "true"} - } + "permissions": { + "public": { + "value": "false", + "option": "false" + }, + "project": { + "value": "true", + "option": "true" + }, + "private": { + "value": "false", + "option": "true" + } + } + }, + "chart": "apps/mongo-express/chart", + "logo": "mongo-express-logo.png", + "updated_on": "2021-03-10T19:45:03.927Z", + "created_on": "2021-02-19T21:34:37.815Z" + } }, - "chart": "apps/mongo-express/chart", - "logo": "mongo-express-logo.png", - "updated_on": "2021-03-10T19:45:03.927Z", - "created_on": "2021-02-19T21:34:37.815Z" - } -}, { - "model": "apps.apps", - "pk": 1, - "fields": { - "name": "MongoDB", - "slug": "mongodb", - "category": "store", - "table_field": { + "model": "apps.apps", + "pk": 1, + "fields": { + "name": "MongoDB", + "slug": "mongodb", + "category": "store", + "table_field": { "info": "mongodb://{{ release }}:27017" }, - "description": "", - "settings": { - "apps":{ - "Persistent Volume":"one" - }, - "default_values": { + "description": "", + "settings": { + "apps": { + "Persistent Volume": "one" + }, + "default_values": { "port": "27017", "targetport": "27017" }, - "credentials": { - "username": {"type": "string", "default":"admin","title":"Username"}, - "password": {"type": "string", "default":"password","title":"Password"} + "credentials": { + "username": { + "type": "string", + "default": "admin", + "title": "Username" + }, + "password": { + "type": "string", + "default": "password", + "title": "Password" + } + }, + "permissions": { + "public": { + "value": "false", + "option": "false" + }, + "project": { + "value": "true", + "option": "true" + }, + "private": { + "value": "false", + "option": "true" + } + } }, - "permissions": { - "public": {"value":"false", "option": "false"}, - "project": {"value":"true", "option": "true"}, - "private": {"value":"false", "option": "true"} - } - }, - "chart": "apps/mongodb/chart", - "logo": "mongodb-logo.png", - "updated_on": "2021-03-10T19:45:03.927Z", - "created_on": "2021-02-19T21:34:37.815Z" - } -}, - { - "model": "apps.apps", - "pk": 2, - "fields": { - "name": "FEDn Combiner", - "slug": "combiner", - "category": "compute", - "table_field": {}, - "description": "", - "settings": { - "apps":{ - "FEDn Reducer":"one", - "Persistent Volume": "one" - }, - "default_values": { - "port": "443", - "targetport": "443" - }, - "environment": { - "name": "from", - "title": "Image", - "quantity": "one", - "type": "match" - }, - "logs": ["combiner"], - "permissions": { - "public": {"value":"false", "option": "false"}, - "project": {"value":"true", "option": "true"}, - "private": {"value":"false", "option": "true"} - } + "chart": "apps/mongodb/chart", + "logo": "mongodb-logo.png", + "updated_on": "2021-03-10T19:45:03.927Z", + "created_on": "2021-02-19T21:34:37.815Z" + } }, - "chart": "apps/fedn-combiner/chart", - "logo": "fedn-combiner-logo.png", - "updated_on": "2021-03-10T19:45:03.927Z", - "created_on": "2021-02-19T21:34:37.815Z" - } -}, { - "model": "apps.apps", - "pk": 4, - "fields": { - "name": "FEDn Reducer", - "slug": "reducer", - "category": "compute", - "table_field": { - "url": "https://{{ release }}.{{ global.domain }}" + "model": "apps.apps", + "pk": 2, + "fields": { + "name": "FEDn Combiner", + "slug": "combiner", + "category": "compute", + "table_field": {}, + "description": "", + "settings": { + "apps": { + "FEDn Reducer": "one", + "Persistent Volume": "one" + }, + "default_values": { + "port": "443", + "targetport": "443" + }, + "environment": { + "name": "from", + "title": "Image", + "quantity": "one", + "type": "match" + }, + "logs": [ + "combiner" + ], + "permissions": { + "public": { + "value": "false", + "option": "false" + }, + "project": { + "value": "true", + "option": "true" + }, + "private": { + "value": "false", + "option": "true" + } + } }, - "description": "", - "settings": { - "S3": "one", - "apps":{ - "MongoDB":"one" + "chart": "apps/fedn-combiner/chart", + "logo": "fedn-combiner-logo.png", + "updated_on": "2021-03-10T19:45:03.927Z", + "created_on": "2021-02-19T21:34:37.815Z" + } + }, + { + "model": "apps.apps", + "pk": 4, + "fields": { + "name": "FEDn Reducer", + "slug": "reducer", + "category": "compute", + "table_field": { + "url": "https://{{ release }}.{{ global.domain }}" }, - "default_values": { - "port": "8090", - "targetport": "8090" - }, - "environment": { - "name": "from", - "title": "Image", - "quantity": "one", - "type": "match" + "description": "", + "settings": { + "S3": "one", + "apps": { + "MongoDB": "one" + }, + "default_values": { + "port": "8090", + "targetport": "8090" + }, + "environment": { + "name": "from", + "title": "Image", + "quantity": "one", + "type": "match" + }, + "logs": [ + "reducer" + ], + "permissions": { + "public": { + "value": "false", + "option": "false" + }, + "project": { + "value": "true", + "option": "true" + }, + "private": { + "value": "false", + "option": "true" + } + } }, - "logs": ["reducer"], - "permissions": { - "public": {"value":"false", "option": "false"}, - "project": {"value":"true", "option": "true"}, - "private": {"value":"false", "option": "true"} - } - }, - "chart": "apps/fedn-reducer/chart", - "logo": "fedn-reducer-logo.png", - "updated_on": "2021-03-10T19:45:03.927Z", - "created_on": "2021-02-19T21:34:37.815Z" - } -}, + "chart": "apps/fedn-reducer/chart", + "logo": "fedn-reducer-logo.png", + "updated_on": "2021-03-10T19:45:03.927Z", + "created_on": "2021-02-19T21:34:37.815Z" + } + }, { "model": "apps.apps", "pk": 3, @@ -210,7 +258,7 @@ "Persistent Volume": "many" }, "flavor": "one", - "default_values":{ + "default_values": { "port": "80", "targetport": "8888" }, @@ -264,12 +312,12 @@ }, "credentials": { "access_key": { - "type": "string", + "type": "password", "default": "accesskey", "title": "Access Key" }, "secret_key": { - "type": "string", + "type": "password", "default": "secretkey123", "title": "Secret Key" } @@ -378,7 +426,7 @@ "is_tar": "False", "port": "5000", "targetport": "8501" - }, + }, "permissions": { "public": { "value": "false", @@ -533,9 +581,24 @@ "settings": { "volume": { "size": { - "type": "string", + "type": "select", + "title": "Size", "default": "1Gi", - "title": "Size" + "user_can_edit": false, + "items": [ + { + "name": "1Gi", + "value": "1Gi" + }, + { + "name": "2Gi", + "value": "2Gi" + }, + { + "name": "5Gi", + "value": "5Gi" + } + ] }, "storageClass": { "type": "string", @@ -579,7 +642,7 @@ "fields": { "name": "VS Code", "slug": "vscode", - "category": "develop", + "category": "compute", "table_field": { "url": "https://{{ release }}.{{ global.domain }}" }, @@ -614,4 +677,4 @@ "created_on": "2021-02-19T21:34:37.815Z" } } -] +] \ No newline at end of file diff --git a/apps/generate_form.py b/apps/generate_form.py index c9f3ab751..bf4a17bab 100644 --- a/apps/generate_form.py +++ b/apps/generate_form.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.db.models import Q +from django.db.models import Case, IntegerField, Q, Value, When from models.models import Model from projects.models import S3, Environment, Flavor, ReleaseName @@ -137,6 +137,8 @@ def get_form_primitives(app_settings, appinstance=[]): if not is_meta_key: parameters_of_key = appinstance.parameters[key] + print(f"_key: {_key}") + if _key in parameters_of_key.keys(): primitives[key][_key][ "default" @@ -200,13 +202,38 @@ def get_form_environments(aset, project, app, appinstance=[]): dep_environment = True if aset["environment"]["type"] == "match": environments["objs"] = Environment.objects.filter( - project=project, app__slug=app.slug + Q(project=project) | Q(project__isnull=True, public=True), + app__slug=app.slug, + ).order_by( + Case( + When(name__contains="- public", then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ), + "-name", ) elif aset["environment"]["type"] == "any": - environments["objs"] = Environment.objects.filter(project=project) + environments["objs"] = Environment.objects.filter( + Q(project=project) | Q(project__isnull=True, public=True) + ).order_by( + Case( + When(name__contains="- public", then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ), + "-name", + ) elif "apps" in aset["environment"]: environments["objs"] = Environment.objects.filter( - project=project, app__slug__in=aset["environment"]["apps"] + Q(project=project) | Q(project__isnull=True, public=True), + app__slug__in=aset["environment"]["apps"], + ).order_by( + Case( + When(name__contains="- public", then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ), + "-name", ) environments["title"] = aset["environment"]["title"] diff --git a/apps/models.py b/apps/models.py index de13001ec..7c4313345 100644 --- a/apps/models.py +++ b/apps/models.py @@ -39,7 +39,7 @@ class Apps(models.Model): logo = models.CharField(max_length=512, null=True, blank=True) name = models.CharField(max_length=512) priority = models.IntegerField(default=100) - projects = models.ManyToManyField("projects.Project") + projects = models.ManyToManyField("projects.Project", blank=True) revision = models.IntegerField(default=1) settings = models.JSONField(blank=True, null=True) slug = models.CharField(max_length=512, blank=True, null=True) @@ -190,7 +190,7 @@ class AppInstance(models.Model): ) state = models.CharField(max_length=50, null=True, blank=True) table_field = models.JSONField(blank=True, null=True) - tags = TagField() + tags = TagField(blank=True) updated_on = models.DateTimeField(auto_now=True) class Meta: diff --git a/apps/setup.py b/apps/setup.py index b78f9a38a..da5fdc98d 100644 --- a/apps/setup.py +++ b/apps/setup.py @@ -10,8 +10,8 @@ package_dir={"apps": "."}, python_requires=">=3.6,<4", install_requires=[ - "django==4.1.7", - "requests==2.28.1", + "django==4.2.1", + "requests==2.31.0", "django-guardian==2.4.0", "celery==5.2.7", "Pillow==9.4.0", diff --git a/apps/tasks.py b/apps/tasks.py index 0ebb17b9f..bde8bb262 100644 --- a/apps/tasks.py +++ b/apps/tasks.py @@ -563,6 +563,9 @@ def sync_mlflow_models(): ~Q(state="Deleted"), project__status="active", app__slug="mlflow" ) for mlflow_app in mlflow_apps: + if mlflow_app.project is None or mlflow_app.project.mlflow is None: + continue + url = "http://{}/{}".format( mlflow_app.project.mlflow.host, "api/2.0/mlflow/model-versions/search", diff --git a/apps/templates/app_table.html b/apps/templates/app_table.html index 37a74931a..91566966e 100644 --- a/apps/templates/app_table.html +++ b/apps/templates/app_table.html @@ -6,7 +6,7 @@ App Name - State + Status Created Actions @@ -24,7 +24,20 @@ {% else %} {% endif %} - {{ appinstance.status.latest.status_type }} + + + {{ appinstance.status.latest.status_type }} + + + {% if category == "serve" %} + + {% if appinstance.access != "public" %} + unpublished + {% else %} + published + {% endif %} + {% endif %} + {{ appinstance.created_on }} {% endfor %} diff --git a/apps/templates/new.html b/apps/templates/new.html index 60e2706e7..4bd16d48c 100644 --- a/apps/templates/new.html +++ b/apps/templates/new.html @@ -66,6 +66,7 @@

{{ app.name }}

{% endfor %} + {% endblock %} \ No newline at end of file diff --git a/apps/tests/test_generate_form.py b/apps/tests/test_generate_form.py index 869d73729..ea1b7dde0 100644 --- a/apps/tests/test_generate_form.py +++ b/apps/tests/test_generate_form.py @@ -3,9 +3,9 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings -from projects.models import Project +from projects.models import Environment, Project -from ..generate_form import get_form_primitives +from ..generate_form import get_form_environments, get_form_primitives from ..models import AppInstance, Apps User = get_user_model() @@ -15,7 +15,17 @@ class GenerateFormTestCase(TestCase): def setUp(self) -> None: self.app_settings_pvc = { "volume": { - "size": {"type": "string", "title": "Size", "default": "1Gi"}, + "size": { + "type": "select", + "title": "Size", + "default": "1Gi", + "user_can_edit": False, + "items": [ + {"name": "1Gi", "value": "1Gi"}, + {"name": "2Gi", "value": "2Gi"}, + {"name": "5Gi", "value": "5Gi"}, + ], + }, "accessModes": { "type": "string", "title": "AccessModes", @@ -33,6 +43,12 @@ def setUp(self) -> None: "project": {"value": "true", "option": "true"}, }, "default_values": {"port": "port", "targetport": "targetport"}, + "environment": { + "name": "from", + "type": "match", + "title": "Image", + "quantity": "one", + }, } self.user = User.objects.create_user("foo1", "foo@test.com", "bar") @@ -47,6 +63,8 @@ def setUp(self) -> None: ) return super().setUp() + # primatives + @override_settings(DISABLED_APP_INSTANCE_FIELDS=[]) def test_get_form_primitives_should_return_complete(self): app_settings = deepcopy(self.app_settings_pvc) @@ -151,10 +169,12 @@ def test_get_form_primitives_should_set_default(self): result_items = result["volume"] result_size = result_items["size"]["default"] + result_size_user_can_edit = result_items["size"]["user_can_edit"] result_access_modes = result_items["accessModes"]["default"] result_storage_class = result_items["storageClass"]["default"] self.assertEqual(result_size, "5Gi") + self.assertFalse(result_size_user_can_edit) self.assertEqual(result_access_modes, "ReadWriteMany") self.assertEqual(result_storage_class, "microk8s-hostpath") @@ -177,3 +197,133 @@ def test_get_form_primitives_should_set_default(self): self.assertEqual(result_size, "1Gi") self.assertEqual(result_access_modes, "ReadWriteMany") self.assertEqual(result_storage_class, "") + + # environments + + def test_get_form_environments_single(self): + environment = Environment( + app=self.app, + project=self.project, + name="test", + slug="test", + repository="test-repo", + image="test-image", + ) + + environment.save() + app_settings = deepcopy(self.app_settings_pvc) + + result = get_form_environments( + app_settings, self.project, self.app, None + ) + + dep_environment, environments = result + + self.assertEqual(dep_environment, True) + + objs = environments["objs"] + + self.assertEqual(len(objs), 1) + + result_item = objs[0] + + self.assertEqual(result_item.name, "test") + self.assertEqual(result_item.slug, "test") + + def test_get_form_environments_with_public(self): + environment = Environment( + app=self.app, + project=self.project, + name="test1", + slug="test1", + repository="test1-repo", + image="test1-image", + ) + + environment.save() + + environment2 = Environment( + app=self.app, + name="test2", + slug="test2", + repository="test2-repo", + image="test2-image", + public=True, + ) + + environment2.save() + + app_settings = deepcopy(self.app_settings_pvc) + + result = get_form_environments( + app_settings, self.project, self.app, None + ) + + dep_environment, environments = result + + self.assertEqual(dep_environment, True) + + objs = environments["objs"] + + self.assertEqual(len(objs), 2) + + number_of_public = 0 + + for obj in objs: + self.assertIn(obj.name, ["test1", "test2"]) + self.assertIn(obj.slug, ["test1", "test2"]) + + if obj.public: + number_of_public += 1 + + self.assertEqual(number_of_public, 1) + + def test_get_form_environments_with_public_and_other_projects(self): + project = Project.objects.create_project( + name="test-perm-generate_form2", + owner=self.user, + description="", + repository="", + ) + + environment = Environment( + app=self.app, + project=project, + name="test1", + slug="test1", + repository="test1-repo", + image="test1-image", + ) + + environment.save() + + environment2 = Environment( + app=self.app, + name="test2", + slug="test2", + repository="test2-repo", + image="test2-image", + public=True, + ) + + environment2.save() + + app_settings = deepcopy(self.app_settings_pvc) + + result = get_form_environments( + app_settings, self.project, self.app, None + ) + + dep_environment, environments = result + + self.assertEqual(dep_environment, True) + + objs = environments["objs"] + + self.assertEqual(len(objs), 1) + + result_item = objs[0] + + self.assertEqual(result_item.name, "test2") + self.assertEqual(result_item.slug, "test2") + self.assertTrue(result_item.public) diff --git a/apps/tests/test_get_status_view.py b/apps/tests/test_get_status_view.py index 99e705ebd..7a5e46ef1 100644 --- a/apps/tests/test_get_status_view.py +++ b/apps/tests/test_get_status_view.py @@ -78,3 +78,19 @@ def test_user_has_no_access(self): response = c.post(url, {"apps": [self.app_instance.id]}) self.assertEqual(response.status_code, 403) + + def test_apps_empty(self): + c = Client() + + response = c.post( + "/accounts/login/", {"username": "foo1", "password": "bar"} + ) + response.status_code + + self.assertEqual(response.status_code, 302) + + url = f"/{self.user.username}/{self.project.slug}/apps/status" + + response = c.post(url, {"apps": []}) + + self.assertEqual(response.status_code, 200) diff --git a/apps/views.py b/apps/views.py index 1ce257312..930edbea0 100644 --- a/apps/views.py +++ b/apps/views.py @@ -159,12 +159,12 @@ def filter_func(): ) class GetStatusView(View): def post(self, request, user, project): - body = request.POST["apps"] if request.POST["apps"] is not None else [] - result = {} + body = request.POST.get("apps", "") - arr = body.split(",") + result = {} - if len(arr) > 0: + if len(body) > 0: + arr = body.split(",") status_success, status_warning = get_status_defs() app_instances = AppInstance.objects.filter(pk__in=arr) @@ -444,17 +444,18 @@ def post(self, request, user, project, app_slug, data=[], wait=False): @permission_required_or_403("can_view_project", (Project, "slug", "project")) def publish(request, user, project, category, ai_id): - print("Publish app {}".format(ai_id)) - print(project) try: app = AppInstance.objects.get(pk=ai_id) - print(app) - # TODO: Check that user is allowed to publish this app. - print("setting public") app.access = "public" - print("saving") + + if app.parameters["permissions"] is not None: + app.parameters["permissions"] = { + "public": True, + "project": False, + "private": False, + } + app.save() - print("done") except Exception as err: print(err) @@ -475,6 +476,14 @@ def unpublish(request, user, project, category, ai_id): try: app = AppInstance.objects.get(pk=ai_id) app.access = "project" + + if app.parameters["permissions"] is not None: + app.parameters["permissions"] = { + "public": False, + "project": True, + "private": False, + } + app.save() except Exception as err: print(err) diff --git a/models/admin.py b/models/admin.py index 857425eb8..c7d00852f 100644 --- a/models/admin.py +++ b/models/admin.py @@ -8,10 +8,10 @@ class ModelAdmin(admin.ModelAdmin): list_display = ("name", "version", "project_name", "object_type_name") def project_name(self, obj): - return obj.project.name + return obj.project.name if obj.project else None def object_type_name(self, obj): - return obj.object_type.name + return obj.object_type.name if obj.object_type else None admin.site.register(Model, ModelAdmin) diff --git a/models/models.py b/models/models.py index 0668bac15..ee4ed3e7e 100644 --- a/models/models.py +++ b/models/models.py @@ -56,6 +56,8 @@ class ObjectType(models.Model): ) app_slug = models.CharField(max_length=100, null=True, blank=True) + enabled = models.BooleanField(default=True) + def __str__(self): return self.name @@ -65,7 +67,6 @@ def upload_headline_path(instance, filename): class Model(models.Model): - objects_version = ModelManager() objects = models.Manager() PRIVATE = "PR" diff --git a/models/setup.py b/models/setup.py index 672f9390f..d45fa4f15 100644 --- a/models/setup.py +++ b/models/setup.py @@ -10,8 +10,8 @@ package_dir={"models": "."}, python_requires=">=3.6,<4", install_requires=[ - "django==4.1.7", - "requests==2.28.1", + "django==4.2.1", + "requests==2.31.0", "django-guardian==2.4.0", "Pillow==9.4.0", "Markdown==3.4.1", diff --git a/models/templates/models/models_details_public.html b/models/templates/models/models_details_public.html index d5cc9ef4e..8d61b3571 100644 --- a/models/templates/models/models_details_public.html +++ b/models/templates/models/models_details_public.html @@ -6,7 +6,6 @@
-

Model Details

@@ -42,7 +41,8 @@
{{ model.name }}

{{ model.uploaded_at }}

- {% if request.user and request.user.is_authenticated and request.user == model.project.owner or request.user.is_superuser %} + {% if request.user and request.user.is_authenticated and request.user == model.project.owner or + request.user.is_superuser %}
@@ -53,26 +53,28 @@
{{ model.name }}

Add tag

-
+ {% csrf_token %} - +
- - {% include './common/autocomplete.html' with str_list=all_tags id_suffix="tags" name="tag" required=True %} + + {% include 'common/autocomplete.html' with str_list=all_tags + id_suffix="tags" name="tag" required=True %}
- +
- +
- +
@@ -82,19 +84,21 @@
{{ model.name }}

Tags

- {% with model.tags|split:"," as tags %} - + {% with model.tags|split:"," as tags %} + {% for tag in tags %} - -
- - {% csrf_token %} -
- {{tag}} -
-
- +
+ + {% csrf_token %} + +
+ {{tag}} +
+
+ {% endfor %} {% endwith %} @@ -103,7 +107,7 @@
{{ model.name }}
- {% endif%} + {% endif %}
Download diff --git a/monitor/setup.py b/monitor/setup.py index 026d0cd6d..43a00a99e 100644 --- a/monitor/setup.py +++ b/monitor/setup.py @@ -10,8 +10,8 @@ package_dir={"monitor": "."}, python_requires=">=3.6,<4", install_requires=[ - "django==4.1.7", - "requests==2.28.1", + "django==4.2.1", + "requests==2.31.0", ], license="Copyright Scaleout Systems AB. See license for details", zip_safe=False, diff --git a/portal/setup.py b/portal/setup.py index 4b394dd02..d22a48990 100644 --- a/portal/setup.py +++ b/portal/setup.py @@ -10,8 +10,8 @@ package_dir={"portal": "."}, python_requires=">=3.6,<4", install_requires=[ - "django==4.1.7", - "requests==2.28.1", + "django==4.2.1", + "requests==2.31.0", "Pillow==9.4.0", ], license="Copyright Scaleout Systems AB. See license for details", diff --git a/projects/models.py b/projects/models.py index 9b9f88e3d..191f14210 100644 --- a/projects/models.py +++ b/projects/models.py @@ -2,14 +2,16 @@ import random import secrets import string +from datetime import timedelta from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.db import models from django.db.models import Q -from django.db.models.signals import pre_delete, pre_save +from django.db.models.signals import post_save, pre_delete, pre_save from django.dispatch import receiver +from django.utils import timezone from django.utils.text import slugify from guardian.shortcuts import assign_perm @@ -42,7 +44,10 @@ class Environment(models.Model): image = models.CharField(max_length=100) name = models.CharField(max_length=100) project = models.ForeignKey( - settings.PROJECTS_MODEL, on_delete=models.CASCADE, null=True + settings.PROJECTS_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, ) registry = models.ForeignKey( settings.APPINSTANCE_MODEL, @@ -52,9 +57,11 @@ class Environment(models.Model): on_delete=models.CASCADE, ) repository = models.CharField(max_length=100, blank=True, null=True) - slug = models.CharField(max_length=100, null=True) + slug = models.CharField(max_length=100, null=True, blank=True) updated_at = models.DateTimeField(auto_now=True) + public = models.BooleanField(default=False) + def __str__(self): return str(self.name) @@ -135,9 +142,22 @@ def __str__(self): return "{} ({})".format(self.name, self.project.slug) +"""Post save signal when creating an mlflow object""" + + +@receiver(post_save, sender=MLFlow) +def create_mlflow(sender, instance, created, **kwargs): + if created: + if instance.project and not instance.project.mlflow: + instance.project.mlflow = instance + instance.project.save() + + # it will become the default objects attribute for a Project model class ProjectManager(models.Manager): - def create_project(self, name, owner, description, repository): + def create_project( + self, name, owner, description, repository, status="active" + ): user_can_create = self.user_can_create(owner) if not user_can_create: @@ -159,6 +179,7 @@ def create_project(self, name, owner, description, repository): description=description, repository=repository, repository_imported=False, + status=status, ) assign_perm("can_view_project", owner, project) @@ -193,6 +214,18 @@ def user_can_create(self, user): or has_perm ) + def get_projects_from_user(self, user): + return self.filter(Q(owner=user) | Q(authorized=user)).distinct() + + def get_project(self, user, slug=None, id=None): + qs = ( + self.filter(Q(owner=user) | Q(authorized=user), pk=id) + if id is not None + else self.filter(Q(owner=user) | Q(authorized=user), slug=slug) + ) + + return qs.first() if qs.count() != 0 else None + def get_random_pattern_class(): randint = random.randint(1, 12) @@ -266,6 +299,17 @@ class Meta: def __str__(self): return "Name: {} ({})".format(self.name, self.status) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.status == "created": + if ( + self.created_at is not None + and self.created_at < timezone.now() - timedelta(minutes=2) + ): + self.status = "active" + self.save() + @receiver(pre_delete, sender=Project) def on_project_delete(sender, instance, **kwargs): @@ -337,6 +381,8 @@ class ProjectTemplate(models.Model): slug = models.CharField(max_length=512, default="") template = models.TextField(null=True, blank=True) + enabled = models.BooleanField(default=True) + class Meta: unique_together = ( "slug", diff --git a/projects/setup.py b/projects/setup.py index b2214b189..b46dded86 100644 --- a/projects/setup.py +++ b/projects/setup.py @@ -10,8 +10,8 @@ package_dir={"projects": "."}, python_requires=">=3.6,<4", install_requires=[ - "django==4.1.7", - "requests==2.28.1", + "django==4.2.1", + "requests==2.31.0", "django-guardian==2.4.0", "celery==5.2.7", "Pillow==9.4.0", diff --git a/projects/tasks.py b/projects/tasks.py index 7678170d4..a00fa7b15 100644 --- a/projects/tasks.py +++ b/projects/tasks.py @@ -147,6 +147,9 @@ def create_resources_from_template(user, project_slug, template): print("Template has either not valid or unknown keys") raise (ProjectCreationException) + project.status = "active" + project.save() + @shared_task def delete_project_apps(project_slug): diff --git a/projects/templates/project_create.html b/projects/templates/project_create.html index a63c0583f..fe1a24730 100644 --- a/projects/templates/project_create.html +++ b/projects/templates/project_create.html @@ -10,56 +10,76 @@

+ + +
+ {% if template %}
+
-
+ +
+ +
+

Create a {{template.name}} project

+
+
+ + {% csrf_token %} -
- -
- -
- Valid project name is required. -
+ +
+
+
-
-
- -
- + {% if form.name.errors %} +
+ {% for error in form.name.errors %} +

{{error|escape}}

+ {% endfor %}
+ {% endif %}
-
- -
-
+ {% if form.description.errors %} +
+ {% for error in form.description.errors %} +

{{error|escape}}

+ {% endfor %} +
+ {% endif %}
-
-
- - + + +
+
+ + Cancel +
+
+ {% else %} + + + {% endif %} +
{% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/index.html b/projects/templates/projects/index.html index 562c256e6..db1d2a3ed 100644 --- a/projects/templates/projects/index.html +++ b/projects/templates/projects/index.html @@ -71,7 +71,7 @@
Delete {% elif request.user.is_superuser %} - + (Admin) Delete {% endif %} diff --git a/projects/templates/projects/overview.html b/projects/templates/projects/overview.html index c0f2b40ae..1de8a394c 100644 --- a/projects/templates/projects/overview.html +++ b/projects/templates/projects/overview.html @@ -3,6 +3,10 @@ {% block content %} + +{% if project.status == "active" %} + +

Overview

@@ -151,7 +155,7 @@
Models
{{ model.name }} {{ model.version }} - {{ model.uploaded_at }} + {{ model.uploaded_at | date:"d/n/y H:i" }} Models

Details + + {% if model.access != "PU" %} - - Settings + href="{% url 'models:publish_model' request.user project.slug model.id %}"> + + Publish + {% else %} + + + Unpublish + + {% endif %} {% include 'common/serve_model_link.html' %} @@ -237,7 +250,7 @@
Models
const loop = async () => { - if (apps) { + if ((apps?.length ?? 0) > 0) { await updateStatus() @@ -251,4 +264,54 @@
Models
} + {% else %} + +
+
+ + {% include 'common/loader.html' %} +
+
+ + + + {% endif %} + + + + {% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/settings.html b/projects/templates/projects/settings.html index 1ccdd730a..63b8f24fc 100644 --- a/projects/templates/projects/settings.html +++ b/projects/templates/projects/settings.html @@ -67,10 +67,14 @@
Project Settings
Environments {% endif %} + + {% if request.user.pk == project.owner.pk %} + Delete + {% endif %} diff --git a/projects/tests/test_create_mlflow.py b/projects/tests/test_create_mlflow.py new file mode 100644 index 000000000..7a01b2a58 --- /dev/null +++ b/projects/tests/test_create_mlflow.py @@ -0,0 +1,57 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from ..models import MLFlow, Project + +User = get_user_model() + + +class CreateMLFlowTestCase(TestCase): + project_name = "test-perm-mlflow" + + def setUp(self) -> None: + self.user = User.objects.create_user("foo1", "foo@test.com", "bar") + self.project = Project.objects.create_project( + name=self.project_name, + owner=self.user, + description="", + repository="", + ) + + def test_no_default_for_project(self): + obj = MLFlow( + name="mlflow1", + project=self.project, + owner=self.user, + ) + + obj.save() + + project = Project.objects.get(name=self.project_name) + + self.assertEqual(project.mlflow.name, obj.name) + + def test_default_existis_for_project(self): + obj = MLFlow( + name="mlflow1", + project=self.project, + owner=self.user, + ) + + obj.save() + + project = Project.objects.get(name=self.project_name) + + self.assertEqual(project.mlflow.name, obj.name) + + obj2 = MLFlow( + name="mlflow2", + project=self.project, + owner=self.user, + ) + + obj2.save() + + project = Project.objects.get(name=self.project_name) + + self.assertEqual(project.mlflow.name, obj.name) diff --git a/projects/tests/test_project_create_view.py b/projects/tests/test_project_create_view.py new file mode 100644 index 000000000..4238e422b --- /dev/null +++ b/projects/tests/test_project_create_view.py @@ -0,0 +1,52 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from ..models import ProjectTemplate + +User = get_user_model() + + +class ProjectCreateViewTestCase(TestCase): + def setUp(self): + self.user = User.objects.create_user("foo", "foo@test.com", "bar") + self.client.login(username="foo", password="bar") + + project_template = ProjectTemplate(name="Template") + project_template.save() + + print("project_template.pk") + print(project_template.pk) + self.template_id = project_template.pk + + def test_project_create_get(self): + response = self.client.get( + "/projects/create?template=Template", + ) + + response.status_code + + self.assertEqual(response.status_code, 200) + + self.assertIsNotNone(response.context["template"]) + self.assertTrue(response.context["template"].id > 0) + + def test_project_create_post(self): + with patch( + "projects.tasks.create_resources_from_template.delay" + ) as mock_task: + response = self.client.post( + "/projects/create?template=Template", + { + "name": "My Project", + "desciption": "My description", + "template_id": self.template_id, + }, + ) + + response.status_code + + self.assertEqual(response.status_code, 302) + + mock_task.assert_called_once() diff --git a/projects/urls.py b/projects/urls.py index 97ac0034e..6b0053135 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -1,9 +1,11 @@ from django.conf import settings +from django.contrib.auth.decorators import login_required from django.urls import path from . import views from .views import ( GrantAccessToProjectView, + ProjectStatusView, RevokeAccessToProjectView, UpdatePatternView, ) @@ -12,7 +14,11 @@ basicpatterns = [ path("projects/", views.IndexView.as_view(), name="index"), - path("projects/create", views.create, name="create"), + path( + "projects/create", + login_required(views.CreateProjectView.as_view()), + name="create", + ), path( "projects/templates", views.project_templates, name="project_templates" ), @@ -57,6 +63,11 @@ RevokeAccessToProjectView.as_view(), name="revoke_access", ), + path( + "//project/status", + ProjectStatusView.as_view(), + name="get_status", + ), ] extrapatterns = [ diff --git a/projects/views.py b/projects/views.py index af996eeb7..ae62c6198 100644 --- a/projects/views.py +++ b/projects/views.py @@ -11,6 +11,7 @@ from django.http import ( HttpResponse, HttpResponseBadRequest, + HttpResponseForbidden, HttpResponseRedirect, JsonResponse, ) @@ -360,6 +361,19 @@ def set_mlflow(request, user, project_slug, mlflow=[]): ) +@method_decorator( + permission_required_or_403( + "can_view_project", (Project, "slug", "project_slug") + ), + name="dispatch", +) +class ProjectStatusView(View): + def get(self, request, user, project_slug): + project = Project.objects.get(slug=project_slug) + + return JsonResponse({"status": project.status}) + + @method_decorator( permission_required_or_403( "can_view_project", (Project, "slug", "project_slug") @@ -369,7 +383,7 @@ def set_mlflow(request, user, project_slug, mlflow=[]): class GrantAccessToProjectView(View): def post(self, request, user, project_slug): selected_username = request.POST["selected_user"] - qs = User.objects.filter(username=selected_username, is_client=False) + qs = User.objects.filter(username=selected_username) if len(qs) == 1: selected_user = qs[0] @@ -452,7 +466,7 @@ def post(self, request, user, project_slug): def project_templates(request): template = "project_templates.html" templates = ( - ProjectTemplate.objects.all() + ProjectTemplate.objects.filter(enabled=True) .order_by("slug", "-revision") .distinct("slug") ) @@ -460,26 +474,30 @@ def project_templates(request): return render(request, template, locals()) -@login_required -def create(request): - template = "project_create.html" - templates = ( - ProjectTemplate.objects.all() - .order_by("slug", "-revision") - .distinct("slug") - ) +class CreateProjectView(View): + template_name = "project_create.html" - template_selected = "STACKn Default" - if "template" in request.GET: - template_selected = request.GET.get("template") + def get(self, request): + pre_selected_template = request.GET.get("template") - if request.method == "POST": + arr = ProjectTemplate.objects.filter(name=pre_selected_template) + + template = arr[0] if len(arr) > 0 else None + + context = {"template": template} + + return render( + request=request, + context=context, + template_name=self.template_name, + ) + + def post(self, request, *args, **kwargs): success = True + template_id = request.POST.get("template_id") name = request.POST.get("name", "default") - access = request.POST.get("access", "org") description = request.POST.get("description", "") - repository = request.POST.get("repository", "") # Try to create database project object. try: @@ -487,7 +505,8 @@ def create(request): name=name, owner=request.user, description=description, - repository=repository, + repository="", + status="created", ) except ProjectCreationException: print("ERROR: Failed to create project database object.") @@ -495,9 +514,7 @@ def create(request): try: # Create resources from the chosen template - project_template = ProjectTemplate.objects.get( - pk=request.POST.get("project-template") - ) + project_template = ProjectTemplate.objects.get(pk=template_id) create_resources_from_template.delay( request.user.username, project.slug, project_template.template ) @@ -533,8 +550,6 @@ def create(request): return HttpResponseRedirect(next_page, {"message": "Created project"}) - return render(request, template, locals()) - @method_decorator( permission_required_or_403( @@ -611,10 +626,19 @@ def delete(request, user, project_slug): next_page = request.GET.get("next", "/projects/") if not request.user.is_superuser: - owner = User.objects.filter(username=user).first() - project = Project.objects.filter( - owner=owner, slug=project_slug - ).first() + users = User.objects.filter(username=user) + + if len(users) != 1: + return HttpResponseBadRequest() + + owner = users[0] + + projects = Project.objects.filter(owner=owner, slug=project_slug) + + if len(projects) != 1: + return HttpResponseForbidden() + + project = projects[0] else: project = Project.objects.filter(slug=project_slug).first() diff --git a/studio/migrations/apps/0012_alter_appinstance_tags_alter_apps_projects.py b/studio/migrations/apps/0012_alter_appinstance_tags_alter_apps_projects.py new file mode 100644 index 000000000..14f31c3aa --- /dev/null +++ b/studio/migrations/apps/0012_alter_appinstance_tags_alter_apps_projects.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.7 on 2023-07-05 11:33 + +from django.db import migrations, models +import tagulous.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0006_environment_public_projecttemplate_enabled_and_more'), + ('apps', '0011_remove_apps_logo_file_apps_user_can_delete_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='appinstance', + name='tags', + field=tagulous.models.fields.TagField(_set_tag_meta=True, blank=True, help_text='Enter a comma-separated tag string', to='apps.tagulous_appinstance_tags'), + ), + migrations.AlterField( + model_name='apps', + name='projects', + field=models.ManyToManyField(blank=True, to='projects.project'), + ), + ] diff --git a/studio/migrations/models/0011_objecttype_enabled.py b/studio/migrations/models/0011_objecttype_enabled.py new file mode 100644 index 000000000..c5026d510 --- /dev/null +++ b/studio/migrations/models/0011_objecttype_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2023-07-05 11:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('models', '0010_alter_model_tags'), + ] + + operations = [ + migrations.AddField( + model_name='objecttype', + name='enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/studio/migrations/projects/0006_environment_public_projecttemplate_enabled_and_more.py b/studio/migrations/projects/0006_environment_public_projecttemplate_enabled_and_more.py new file mode 100644 index 000000000..199ef1756 --- /dev/null +++ b/studio/migrations/projects/0006_environment_public_projecttemplate_enabled_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.1.7 on 2023-07-05 11:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0005_remove_project_project_image_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='environment', + name='public', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='projecttemplate', + name='enabled', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='environment', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='projects.project'), + ), + migrations.AlterField( + model_name='environment', + name='slug', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/templates/common/footer.html b/templates/common/footer.html index cb13aaf18..3f20cc478 100644 --- a/templates/common/footer.html +++ b/templates/common/footer.html @@ -4,7 +4,7 @@
-
+ -
+

- © 2020-2021 - Scaleout Systems AB + © 2020-2021 - Scaleout Systems + AB

{% get_setting "VERSION" %}

diff --git a/templates/common/loader.html b/templates/common/loader.html new file mode 100644 index 000000000..a2b6d8dbc --- /dev/null +++ b/templates/common/loader.html @@ -0,0 +1,55 @@ + + + + \ No newline at end of file