From 42a82d32cc0c66daade71ab5924a98039eeeaa45 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Mon, 20 Nov 2023 16:50:39 +0100 Subject: [PATCH 01/19] feat(django_agent): handle prefix setting --- .../forestadmin/django_agent/urls.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/django_agent/forestadmin/django_agent/urls.py b/src/django_agent/forestadmin/django_agent/urls.py index 8e37756b6..2877406a3 100644 --- a/src/django_agent/forestadmin/django_agent/urls.py +++ b/src/django_agent/forestadmin/django_agent/urls.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.urls import path from .views import actions, authentication, charts, crud, crud_related, index, stats @@ -5,41 +6,49 @@ app_name = "django_agent" +prefix = getattr(settings, "FOREST_PREFIX", "") +if len(prefix) > 0 and prefix[-1] != "/": + prefix = f"{prefix}/" + urlpatterns = [ # generic - path("forest/", index.index, name="index"), - path("forest/scope-cache-invalidation", index.scope_cache_invalidation, name="scope_invalidation"), + path(f"{prefix}forest/", index.index, name="index"), + path(f"{prefix}forest/scope-cache-invalidation", index.scope_cache_invalidation, name="scope_invalidation"), # authentication - path("forest/authentication", authentication.authentication, name="authentication"), - path("forest/authentication/callback", authentication.callback, name="authentication_callback"), + path(f"{prefix}forest/authentication", authentication.authentication, name="authentication"), + path(f"{prefix}forest/authentication/callback", authentication.callback, name="authentication_callback"), # actions - path("forest/_actions///hooks/load", actions.hook, name="action_hook_load"), - path("forest/_actions///hooks/change", actions.hook, name="action_hook_change"), - path("forest/_actions//", actions.execute, name="action_execute"), + path(f"{prefix}forest/_actions///hooks/load", actions.hook, name="action_hook_load"), + path(f"{prefix}forest/_actions///hooks/change", actions.hook, name="action_hook_change"), + path(f"{prefix}forest/_actions//", actions.execute, name="action_execute"), # charts - path("forest/_charts/", charts.chart_datasource, name="datasource_chart"), - path("forest/_charts//", charts.chart_collection, name="collection_chart"), + path(f"{prefix}forest/_charts/", charts.chart_datasource, name="datasource_chart"), + path( + f"{prefix}forest/_charts//", + charts.chart_collection, + name="collection_chart", + ), # stats - path("forest/stats/", stats.stats, name="stats"), + path(f"{prefix}forest/stats/", stats.stats, name="stats"), # crud related path( - "forest///relationships//count", + f"{prefix}forest///relationships//count", crud_related.count, name="crud_related_count", ), path( - "forest///relationships/.csv", + f"{prefix}forest///relationships/.csv", crud_related.csv, name="crud_related_csv", ), path( - "forest///relationships/", + f"{prefix}forest///relationships/", crud_related.list_, name="crud_related_list", ), # crud - path("forest/.csv", crud.csv, name="crud_csv"), - path("forest//count", crud.count, name="crud_count"), - path("forest//", crud.detail, name="crud_detail"), - path("forest/", crud.list_, name="crud_list"), + path(f"{prefix}forest/.csv", crud.csv, name="crud_csv"), + path(f"{prefix}forest//count", crud.count, name="crud_count"), + path(f"{prefix}forest//", crud.detail, name="crud_detail"), + path(f"{prefix}forest/", crud.list_, name="crud_list"), ] From 154fd1bc464b8e48ad8f42a901d77f8b4ce411c3 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Mon, 20 Nov 2023 18:04:11 +0100 Subject: [PATCH 02/19] feat(django_agent): finishing agent integration --- .../forestadmin/agent_toolkit/agent.py | 2 +- .../forestadmin/django_agent/agent.py | 21 ++++++- .../forestadmin/django_agent/apps.py | 62 +++++++++++++++++-- src/django_agent/pyproject.toml | 10 +-- 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/agent.py b/src/agent_toolkit/forestadmin/agent_toolkit/agent.py index d3156ab30..ea76e53f0 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/agent.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/agent.py @@ -218,5 +218,5 @@ async def _start(self): if self.options["instant_cache_refresh"]: self._sse_thread.start() - ForestLogger.log("info", "Agent started") + ForestLogger.log("debug", "Agent started") Agent.__IS_INITIALIZED = True diff --git a/src/django_agent/forestadmin/django_agent/agent.py b/src/django_agent/forestadmin/django_agent/agent.py index a592783c0..f6cd31641 100644 --- a/src/django_agent/forestadmin/django_agent/agent.py +++ b/src/django_agent/forestadmin/django_agent/agent.py @@ -1,8 +1,11 @@ import asyncio +import importlib import os import sys +from typing import Optional import pkg_resources +from django.conf import ENVIRONMENT_VARIABLE as DJANGO_SETTING_MODULE_ENV_VAR_NAME from django.conf import settings from forestadmin.agent_toolkit.agent import Agent as BaseAgent from forestadmin.agent_toolkit.forest_logger import ForestLogger @@ -20,12 +23,19 @@ class DjangoAgent(BaseAgent): "stack": {"engine": "python", "engine_version": ".".join(map(str, [*sys.version_info[:3]]))}, } - def __init__(self): + def __init__(self, config: Optional[Options] = None): self.loop = asyncio.new_event_loop() - super(DjangoAgent, self).__init__(self.__parse_config()) + config = config if config is not None else self.__parse_config() + super(DjangoAgent, self).__init__(config) def __parse_config(self): - options: Options = {"schema_path": os.path.join(settings.BASE_DIR, ".forestadmin-schema.json")} + if hasattr(settings, "BASE_DIR"): + base_dir = settings.BASE_DIR + else: + setting_file = importlib.import_module(os.environ[DJANGO_SETTING_MODULE_ENV_VAR_NAME]).__file__ + base_dir = os.path.abspath(os.path.join(setting_file, "..", "..")) + + options: Options = {"schema_path": os.path.join(base_dir, ".forestadmin-schema.json")} for setting_name in dir(settings): if not setting_name.upper().startswith("FOREST_"): continue @@ -51,3 +61,8 @@ def __parse_config(self): def start(self): self.loop.run_until_complete(self._start()) ForestLogger.log("info", "Django agent initialized") + + +def create_agent(config: Optional[Options] = None): + agent = DjangoAgent(config) + return agent diff --git a/src/django_agent/forestadmin/django_agent/apps.py b/src/django_agent/forestadmin/django_agent/apps.py index 5882cedd5..c0392114d 100644 --- a/src/django_agent/forestadmin/django_agent/apps.py +++ b/src/django_agent/forestadmin/django_agent/apps.py @@ -1,7 +1,19 @@ +import asyncio +import importlib +import sys +from typing import Callable, Union + from django.apps import AppConfig +from django.conf import settings +from forestadmin.agent_toolkit.forest_logger import ForestLogger +from forestadmin.datasource_django.datasource import DjangoDatasource +from forestadmin.django_agent.agent import DjangoAgent, create_agent + -# from forestadmin.datasource_django.datasource import DjangoDatasource -from forestadmin.django_agent.agent import DjangoAgent +def is_launch_as_server() -> bool: + is_manage_py = any(arg.casefold().endswith("manage.py") for arg in sys.argv) + is_runserver = any(arg.casefold() == "runserver" for arg in sys.argv) + return (is_manage_py and is_runserver) or (not is_manage_py) class DjangoAgentApp(AppConfig): @@ -9,9 +21,49 @@ class DjangoAgentApp(AppConfig): name = "forestadmin.django_agent" @classmethod - def get_agent(cls): + def get_agent(cls) -> DjangoAgent: + if cls._DJANGO_AGENT is None: + ForestLogger.log( + "warning", + "Trying to get the agent but it's not created. Did you have a forest error before? " + "If not, this is no normal. " + "May be you are trying to get the agent too early or during a manage command other than 'runserver' ?", + ) + return cls._DJANGO_AGENT def ready(self): - DjangoAgentApp._DJANGO_AGENT = DjangoAgent() - # DjangoAgentApp._DJANGO_AGENT.add_datasource(DjangoDatasource()) + if is_launch_as_server(): + DjangoAgentApp._DJANGO_AGENT = create_agent() + if not getattr(settings, "FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE", None): + DjangoAgentApp._DJANGO_AGENT.add_datasource(DjangoDatasource()) + + customize_fn = getattr(settings, "FOREST_CUSTOMIZE_FUNCTION", None) + if customize_fn: + self._call_user_customize_function(customize_fn) + DjangoAgentApp._DJANGO_AGENT.start() + + def _call_user_customize_function(self, customize_fn: Union[str, Callable[[DjangoAgent], None]]): + if isinstance(customize_fn, str): + try: + module_name, fn_name = customize_fn.rsplit(".", 1) + module = importlib.import_module(module_name) + customize_fn = getattr(module, fn_name) + except Exception as exc: + ForestLogger.log("error", f"cannot import {customize_fn} : {exc}. Quitting forest.") + DjangoAgentApp._DJANGO_AGENT = None + return + + if callable(customize_fn): + try: + if asyncio.iscoroutinefunction(customize_fn): + DjangoAgentApp._DJANGO_AGENT.loop.run_until_complete(customize_fn(DjangoAgentApp._DJANGO_AGENT)) + else: + customize_fn(DjangoAgentApp._DJANGO_AGENT) + except Exception as exc: + ForestLogger.log( + "error", + f'error executing "FOREST_CUSTOMIZE_FUNCTION" ({customize_fn}): {exc}. Quitting forest.', + ) + DjangoAgentApp._DJANGO_AGENT = None + return diff --git a/src/django_agent/pyproject.toml b/src/django_agent/pyproject.toml index 968337ddd..f1b74c5ff 100644 --- a/src/django_agent/pyproject.toml +++ b/src/django_agent/pyproject.toml @@ -16,7 +16,7 @@ python = ">=3.8,<4.0" typing-extensions = "~=4.2" tzdata = "~=2022.6" forestadmin-agent-toolkit = "1.1.0" -forestadmin-datasource-toolkit = "1.1.0" +forestadmin-datasource-django = "1.1.0" django = ">=3.2" [tool.poetry.dependencies."backports.zoneinfo"] @@ -58,14 +58,10 @@ black = "~=22.10" [tool.poetry.group.sorter.dependencies] isort = "~=3.6" -[tool.poetry.group.test.dependencies.forestadmin-datasource-toolkit] -path = "../datasource_toolkit" +[tool.poetry.group.test.dependencies.forestadmin-datasource-django] +path = "../datasource_django" develop = true -# [tool.poetry.group.test.dependencies.forestadmin-datasource-django] -# path = "../datasource_django" -# develop = true - [tool.poetry.group.test.dependencies.forestadmin-agent-toolkit] path = "../agent_toolkit" develop = true From d2bdfa70f67389abee71038eee58450ec4c84fa9 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Mon, 20 Nov 2023 18:05:19 +0100 Subject: [PATCH 03/19] chore(example): update django example project --- src/_example/django/django_demo/app/apps.py | 10 +--------- .../django/django_demo/django_demo/settings.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/_example/django/django_demo/app/apps.py b/src/_example/django/django_demo/app/apps.py index 9909ed21e..bcfe39bb2 100644 --- a/src/_example/django/django_demo/app/apps.py +++ b/src/_example/django/django_demo/app/apps.py @@ -1,14 +1,6 @@ -from app.forest_admin import customize_forest -from django.apps import AppConfig, apps -from forestadmin.django_agent.agent import DjangoAgent +from django.apps import AppConfig class AppConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "app" - - def ready(self) -> None: - agent: DjangoAgent = apps.get_app_config("django_agent").get_agent() - if agent: - customize_forest(agent) - agent.start() diff --git a/src/_example/django/django_demo/django_demo/settings.py b/src/_example/django/django_demo/django_demo/settings.py index e5a44d7fa..efadfb0a0 100644 --- a/src/_example/django/django_demo/django_demo/settings.py +++ b/src/_example/django/django_demo/django_demo/settings.py @@ -37,6 +37,13 @@ FOREST_ENV_SECRET = os.environ.get("FOREST_ENV_SECRET") FOREST_SERVER_URL = os.environ.get("FOREST_SERVER_URL") FOREST_IS_PRODUCTION = str2bool(os.environ.get("FOREST_IS_PRODUCTION", "False")) +# if you want to manually add datasource with option you can set this var to True and +# add a datasource in the 'FOREST_CUSTOMIZE_FUNCTION' +# FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE = False + +FOREST_CUSTOMIZE_FUNCTION = "app.forest_admin.customize_forest" +# from app.forest_admin import customize_forest +# FOREST_CUSTOMIZE_FUNCTION = app.forest_admin.customize_forest # CORS for forest CORS_ALLOWED_ORIGIN_REGEXES = [ @@ -95,7 +102,7 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -140,10 +147,3 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } -} From c6732c44cbcb0be3b747812ecabb350b03e9d874 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 14:59:20 +0100 Subject: [PATCH 04/19] chore(example): update example project --- .../django_demo/.forestadmin-schema.json | 393 +++++++++++++++--- .../django/django_demo/app/forest/address.py | 68 +++ .../django/django_demo/app/forest/cart.py | 21 + .../django/django_demo/app/forest/customer.py | 338 ++++++++++++++- .../django/django_demo/app/forest/order.py | 166 ++++++++ .../django/django_demo/app/forest_admin.py | 214 +++++++++- .../app/management/commands/populate-db.py | 98 +++-- 7 files changed, 1190 insertions(+), 108 deletions(-) create mode 100644 src/_example/django/django_demo/app/forest/address.py create mode 100644 src/_example/django/django_demo/app/forest/cart.py create mode 100644 src/_example/django/django_demo/app/forest/order.py diff --git a/src/_example/django/django_demo/.forestadmin-schema.json b/src/_example/django/django_demo/.forestadmin-schema.json index b390d2b35..d1ce14113 100644 --- a/src/_example/django/django_demo/.forestadmin-schema.json +++ b/src/_example/django/django_demo/.forestadmin-schema.json @@ -11,7 +11,12 @@ "paginationType": "page", "searchField": null, "actions": [], - "segments": [], + "segments": [ + { + "id": "Address.highOrderDelivery", + "name": "highOrderDelivery" + } + ], "fields": [ { "defaultValue": null, @@ -59,26 +64,20 @@ ] }, { - "defaultValue": "France", + "defaultValue": null, "enums": null, - "field": "country", + "field": "complete_address", "integration": null, "inverseOf": null, - "isFilterable": true, + "isFilterable": false, "isPrimaryKey": false, - "isReadOnly": false, + "isReadOnly": true, "isRequired": false, "isSortable": true, "isVirtual": false, "reference": null, "type": "String", - "validations": [ - { - "type": "is shorter than", - "value": 254, - "message": null - } - ] + "validations": [] }, { "defaultValue": null, @@ -151,25 +150,20 @@ "validations": [] }, { - "defaultValue": null, + "defaultValue": "France", "enums": null, - "field": "number", + "field": "pays", "integration": null, "inverseOf": null, "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": true, + "isRequired": false, "isSortable": true, "isVirtual": false, "reference": null, "type": "String", "validations": [ - { - "type": "is present", - "value": null, - "message": null - }, { "type": "is shorter than", "value": 254, @@ -177,6 +171,43 @@ } ] }, + { + "defaultValue": null, + "enums": null, + "field": "postal_code", + "integration": null, + "inverseOf": null, + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": true, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": null, + "type": [ + { + "fields": [ + { + "field": "codePostal", + "type": "String" + }, + { + "field": "codeCommune", + "type": "String" + }, + { + "field": "nomCommune", + "type": "String" + }, + { + "field": "libelleAcheminement", + "type": "String" + } + ] + } + ], + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -239,7 +270,12 @@ "paginationType": "page", "searchField": null, "actions": [], - "segments": [], + "segments": [ + { + "id": "Cart.No order", + "name": "No order" + } + ], "fields": [ { "defaultValue": null, @@ -263,7 +299,7 @@ "field": "customer_id", "integration": null, "inverseOf": null, - "isFilterable": true, + "isFilterable": false, "isPrimaryKey": false, "isReadOnly": true, "isRequired": false, @@ -465,9 +501,83 @@ "onlyForRelationships": false, "paginationType": "page", "searchField": null, - "actions": [], - "segments": [], + "actions": [ + { + "id": "Customer-0-export json", + "name": "Export json", + "type": "bulk", + "baseUrl": null, + "endpoint": "/forest/_actions/Customer/0/export json", + "httpMethod": "POST", + "redirect": null, + "download": true, + "fields": [], + "hooks": { + "load": false, + "change": [ + "changeHook" + ] + } + }, + { + "id": "Customer-1-age operation", + "name": "Age operation", + "type": "single", + "baseUrl": null, + "endpoint": "/forest/_actions/Customer/1/age operation", + "httpMethod": "POST", + "redirect": null, + "download": false, + "fields": [ + { + "field": "Loading...", + "type": "String", + "isReadOnly": true, + "defaultValue": "Form is loading", + "value": null, + "description": "", + "enums": null, + "hook": null, + "isRequired": false, + "reference": null, + "widget": null + } + ], + "hooks": { + "load": true, + "change": [ + "changeHook" + ] + } + } + ], + "segments": [ + { + "id": "Customer.VIP customers", + "name": "VIP customers" + }, + { + "id": "Customer.with french address", + "name": "with french address" + } + ], "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "TotalSpending", + "integration": null, + "inverseOf": null, + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": true, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -486,6 +596,22 @@ ], "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "age", + "integration": null, + "inverseOf": null, + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": true, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -579,6 +705,22 @@ } ] }, + { + "defaultValue": null, + "enums": null, + "field": "full_name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -695,17 +837,19 @@ { "defaultValue": null, "enums": null, - "field": "total_spending", - "integration": null, + "field": "smart_delivering_addresses", "inverseOf": null, "isFilterable": false, "isPrimaryKey": false, - "isReadOnly": true, + "isReadOnly": false, "isRequired": false, - "isSortable": false, + "isSortable": true, "isVirtual": false, - "reference": null, - "type": "Number", + "reference": "Address.id", + "relationship": "BelongsToMany", + "type": [ + "Number" + ], "validations": [] }, { @@ -1186,31 +1330,110 @@ "onlyForRelationships": false, "paginationType": "page", "searchField": null, - "actions": [], - "segments": [], - "fields": [ - { - "defaultValue": null, - "enums": null, - "field": "amount", - "integration": null, - "inverseOf": null, - "isFilterable": true, - "isPrimaryKey": false, - "isReadOnly": false, - "isRequired": true, - "isSortable": true, - "isVirtual": false, - "reference": null, - "type": "Number", - "validations": [ + "actions": [ + { + "id": "Order-0-export json", + "name": "Export json", + "type": "global", + "baseUrl": null, + "endpoint": "/forest/_actions/Order/0/export json", + "httpMethod": "POST", + "redirect": null, + "download": true, + "fields": [ { - "type": "is present", - "value": null, - "message": null + "field": "dummy field", + "value": "", + "defaultValue": "", + "description": "", + "enums": null, + "hook": null, + "isReadOnly": false, + "isRequired": false, + "reference": null, + "type": "String", + "widget": null + }, + { + "field": "customer", + "value": "", + "defaultValue": "", + "description": "", + "enums": null, + "hook": null, + "isReadOnly": false, + "isRequired": true, + "reference": "Customer.id", + "type": "Number", + "widget": null } - ] + ], + "hooks": { + "load": false, + "change": [ + "changeHook" + ] + } + }, + { + "id": "Order-1-refund order(s)", + "name": "Refund order(s)", + "type": "bulk", + "baseUrl": null, + "endpoint": "/forest/_actions/Order/1/refund order(s)", + "httpMethod": "POST", + "redirect": null, + "download": false, + "fields": [ + { + "field": "reason", + "value": "", + "defaultValue": "", + "description": "", + "enums": null, + "hook": null, + "isReadOnly": false, + "isRequired": false, + "reference": null, + "type": "String", + "widget": null + } + ], + "hooks": { + "load": false, + "change": [ + "changeHook" + ] + } + } + ], + "segments": [ + { + "id": "Order.Delivered order", + "name": "Delivered order" + }, + { + "id": "Order.Dispatched order", + "name": "Dispatched order" }, + { + "id": "Order.Pending order", + "name": "Pending order" + }, + { + "id": "Order.Rejected order", + "name": "Rejected order" + }, + { + "id": "Order.Suspicious order", + "name": "Suspicious order" + }, + { + "id": "Order.newly_created", + "name": "newly_created" + } + ], + "fields": [ { "defaultValue": null, "enums": null, @@ -1249,6 +1472,38 @@ "type": "Number", "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "cost", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is greater than", + "value": 0, + "message": null + }, + { + "type": "is greater than", + "value": 0, + "message": null + } + ] + }, { "defaultValue": null, "enums": null, @@ -1281,6 +1536,38 @@ "type": "Number", "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "customer_first_name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "customer_full_name", + "integration": null, + "inverseOf": null, + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": true, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -2129,6 +2416,6 @@ "engine": "python", "engine_version": "3.10.11" }, - "schemaFileHash": "7918f978695f6bd166bbca20ca9de57f53a49a08" + "schemaFileHash": "8c5ecc3606129fe4a61419cedc3fc5e64c1fc908" } } \ No newline at end of file diff --git a/src/_example/django/django_demo/app/forest/address.py b/src/_example/django/django_demo/app/forest/address.py new file mode 100644 index 000000000..a7dbf427c --- /dev/null +++ b/src/_example/django/django_demo/app/forest/address.py @@ -0,0 +1,68 @@ +from typing import List, Tuple + +from aiohttp import ClientSession +from forestadmin.datasource_toolkit.context.collection_context import CollectionCustomizationContext +from forestadmin.datasource_toolkit.decorators.computed.types import ComputedDefinition +from forestadmin.datasource_toolkit.interfaces.fields import Operator, PrimitiveType +from forestadmin.datasource_toolkit.interfaces.query.aggregation import ( + Aggregation, + PlainAggregation, + PlainAggregationGroup, +) +from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.leaf import ConditionTreeLeaf +from forestadmin.datasource_toolkit.interfaces.query.filter.unpaginated import Filter +from forestadmin.datasource_toolkit.interfaces.records import RecordsDataAlias + +# segments + + +async def high_delivery_address_segment(context: CollectionCustomizationContext): + rows = await context.datasource.get_collection("Order").aggregate( + context.caller, + Filter({}), + Aggregation( + component=PlainAggregation( + PlainAggregation(operation="Count", groups=[PlainAggregationGroup(field="delivering_address_id")]) + ) + ), + 100, + ) + return ConditionTreeLeaf( + field="id", operator=Operator.IN, value=[row["group"]["delivering_address_id"] for row in rows] + ) + + +# computed fields +def address_full_name_computed(country_field_name: str) -> Tuple[str, ComputedDefinition]: + async def _get_full_address_values(records: List[RecordsDataAlias], _: CollectionCustomizationContext): + return [f"{record['street']} {record['city']} {record[country_field_name]}" for record in records] + + return ComputedDefinition( + column_type=PrimitiveType.STRING, + dependencies=["street", country_field_name, "city"], + get_values=_get_full_address_values, + ) + # or { + # "column_type": PrimitiveType.STRING, + # "dependencies": ["street", country_field_name, "city"], + # "get_values": _get_full_address_values, + # }, + + +def computed_full_address_caps(): + return ComputedDefinition( + column_type=PrimitiveType.STRING, + dependencies=["full address"], + get_values=lambda records, context: [record["full address"].upper() for record in records], + ) + + +async def get_postal_code(record: RecordsDataAlias, context: CollectionCustomizationContext): + async with ClientSession() as session: + async with session.get( + f"https://apicarto.ign.fr/api/codes-postaux/communes/{record['zip_code']}", verify_ssl=False + ) as response: + if response.status == 200: + return await response.json() + else: + return [] diff --git a/src/_example/django/django_demo/app/forest/cart.py b/src/_example/django/django_demo/app/forest/cart.py new file mode 100644 index 000000000..2ee2c2cb0 --- /dev/null +++ b/src/_example/django/django_demo/app/forest/cart.py @@ -0,0 +1,21 @@ +from forestadmin.datasource_toolkit.decorators.write.write_replace.write_customization_context import ( + WriteCustomizationContext, +) + + +# field writing +async def cart_update_name(value, context: WriteCustomizationContext): + s_val = value.split(" ") + s_val.reverse() + amount = None + for word in s_val: + try: + amount = float(word) if "." in word else int(word) + break + except Exception: + continue + if amount is None: + ret = {"name": value} + else: + ret = {"name": value, "order": {"amount": amount}} + return ret diff --git a/src/_example/django/django_demo/app/forest/customer.py b/src/_example/django/django_demo/app/forest/customer.py index 5b679e9ce..4059901b5 100644 --- a/src/_example/django/django_demo/app/forest/customer.py +++ b/src/_example/django/django_demo/app/forest/customer.py @@ -1,33 +1,327 @@ +import io +import json +import logging +from operator import add, sub +from typing import List, Union + from forestadmin.datasource_toolkit.context.collection_context import CollectionCustomizationContext -from forestadmin.datasource_toolkit.interfaces.fields import Operator -from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation +from forestadmin.datasource_toolkit.decorators.action.context.bulk import ActionContextBulk +from forestadmin.datasource_toolkit.decorators.action.context.single import ActionContextSingle +from forestadmin.datasource_toolkit.decorators.action.result_builder import ResultBuilder +from forestadmin.datasource_toolkit.decorators.action.types.actions import ActionDict +from forestadmin.datasource_toolkit.decorators.chart.collection_chart_context import CollectionChartContext +from forestadmin.datasource_toolkit.decorators.chart.result_builder import ResultBuilder as ResultBuilderChart +from forestadmin.datasource_toolkit.decorators.computed.types import ComputedDefinition +from forestadmin.datasource_toolkit.decorators.hook.context.create import HookBeforeCreateContext +from forestadmin.datasource_toolkit.decorators.hook.context.list import HookAfterListContext +from forestadmin.datasource_toolkit.decorators.write.write_replace.write_customization_context import ( + WriteCustomizationContext, +) +from forestadmin.datasource_toolkit.interfaces.actions import ActionFieldType, ActionResult, ActionsScope +from forestadmin.datasource_toolkit.interfaces.fields import Operator, PrimitiveType +from forestadmin.datasource_toolkit.interfaces.query.aggregation import ( + Aggregation, + PlainAggregation, + PlainAggregationGroup, +) +from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.base import ConditionTree +from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.branch import Aggregator, ConditionTreeBranch from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.leaf import ConditionTreeLeaf +from forestadmin.datasource_toolkit.interfaces.query.filter.paginated import PaginatedFilter from forestadmin.datasource_toolkit.interfaces.query.filter.unpaginated import Filter +from forestadmin.datasource_toolkit.interfaces.query.projections import Projection +from forestadmin.datasource_toolkit.interfaces.records import CompositeIdAlias, RecordsDataAlias + + +# segments +def french_address_segment(context: CollectionCustomizationContext): + return ConditionTreeLeaf(field="addresses:country", operator=Operator.EQUAL, value="France") + + +# computed fields +def customer_spending_computed(): + async def get_customer_spending_values(records: List[RecordsDataAlias], context: CollectionCustomizationContext): + record_ids = [record["id"] for record in records] + condition = Filter( + {"condition_tree": ConditionTreeLeaf(field="customer_id", operator=Operator.IN, value=record_ids)} + ) + aggregation = Aggregation( + component=PlainAggregation( + operation="Sum", + field="amount", + groups=[PlainAggregationGroup(field="customer_id")], + ), + ) + rows = await context.datasource.get_collection("Order").aggregate(context.caller, condition, aggregation) + ret = [] + for record in records: + filtered = [*filter(lambda r: r["group"]["customer_id"] == record["id"], rows)] + row = filtered[0] if len(filtered) > 0 else {} + ret.append(row.get("value", 0)) -async def get_customer_spent_values(records, context: CollectionCustomizationContext): - record_ids = [record["id"] for record in records] - condition = Filter( - {"condition_tree": ConditionTreeLeaf(field="customer_id", operator=Operator.IN, value=record_ids)} + return ret + + return ComputedDefinition( + column_type=PrimitiveType.NUMBER, dependencies=["id"], get_values=get_customer_spending_values ) - aggregation = Aggregation( - { - "operation": "Sum", - "field": "amount", - "groups": [ - { - "field": "customer_id", - } + +def customer_full_name() -> ComputedDefinition: + async def _get_customer_fullname_values(records: List[RecordsDataAlias], context: CollectionCustomizationContext): + return [f"{record['first_name']} - {record['last_name']}" for record in records] + + return ComputedDefinition( + column_type=PrimitiveType.STRING, + dependencies=["first_name", "last_name"], + get_values=_get_customer_fullname_values, + ) + # or + # return { + # "column_type": PrimitiveType.STRING, + # "dependencies": ["first_name", "last_name"], + # "get_values": _get_customer_fullname_values, + # } + + +def customer_full_name_write(value: str, context: WriteCustomizationContext): + first_name, last_name = value.split(" - ", 1) + return {"first_name": first_name, "last_name": last_name} + + +# operator +async def full_name_equal(value, context: CollectionCustomizationContext) -> ConditionTree: + first_name, last_name = value.split(" - ") + return ConditionTreeBranch( + Aggregator.AND, + [ + ConditionTreeLeaf("first_name", Operator.EQUAL, first_name), + ConditionTreeLeaf("last_name", Operator.EQUAL, last_name), + ], + ) + + +async def full_name_less_than(value, context: CollectionCustomizationContext): + return ConditionTreeBranch( + Aggregator.OR, + [ + ConditionTreeLeaf("first_name", Operator.LESS_THAN, value), + ConditionTreeBranch( + Aggregator.AND, + [ + ConditionTreeLeaf("first_name", Operator.EQUAL, value), + ConditionTreeLeaf("last_name", Operator.LESS_THAN, value), + ], + ), + ], + ) + + +async def full_name_greater_than(value, context: CollectionCustomizationContext): + return ConditionTreeBranch( + Aggregator.OR, + [ + ConditionTreeLeaf("first_name", Operator.GREATER_THAN, value), + ConditionTreeBranch( + Aggregator.AND, + [ + ConditionTreeLeaf("first_name", Operator.EQUAL, value), + ConditionTreeLeaf("last_name", Operator.GREATER_THAN, value), + ], + ), + ], + ) + + +async def full_name_in(value, context: CollectionCustomizationContext): + conditions = [] + for v in value: + conditions.append(await full_name_equal(v, context)) + return ConditionTreeBranch(Aggregator.OR, conditions) + + +async def full_name_not_in(value, context: CollectionCustomizationContext): + condition_tree = await full_name_in(value, context) + return condition_tree.inverse() + + +async def full_name_like(value, context: CollectionCustomizationContext): + return ConditionTreeBranch( + Aggregator.OR, + [ + ConditionTreeLeaf("first_name", Operator.LIKE, value), + ConditionTreeLeaf("last_name", Operator.LIKE, value), + ], + ) + + +async def full_name_not_contains(value, context: CollectionCustomizationContext): + if " - " in value: + first_name, last_name = value.split(" - ") + return ConditionTreeBranch( + Aggregator.AND, + [ + ConditionTreeLeaf("first_name", Operator.NOT_CONTAINS, first_name), + ConditionTreeLeaf("last_name", Operator.NOT_CONTAINS, last_name), ], + ) + else: + return ConditionTreeBranch( + Aggregator.AND, + [ + ConditionTreeLeaf("first_name", Operator.NOT_CONTAINS, value), + ConditionTreeLeaf("last_name", Operator.NOT_CONTAINS, value), + ], + ) + + +async def full_name_contains(value, context: CollectionCustomizationContext): + if " - " in value: + first_name, last_name = value.split(" - ") + return ConditionTreeBranch( + Aggregator.AND, + [ + ConditionTreeLeaf("first_name", Operator.CONTAINS, first_name), + ConditionTreeLeaf("last_name", Operator.CONTAINS, last_name), + ], + ) + else: + return ConditionTreeBranch( + Aggregator.AND, + [ + ConditionTreeLeaf("first_name", Operator.CONTAINS, value), + ConditionTreeLeaf("last_name", Operator.CONTAINS, value), + ], + ) + + +# actions +# ######## Export json + + +async def export_customers_json(context: ActionContextBulk, result_builder: ResultBuilder) -> Union[None, ActionResult]: + records = await context.get_records(Projection("id", "full name", "age")) + return result_builder.file( + io.BytesIO(json.dumps({"data": records}).encode("utf-8")), + "dumps.json", + "application/json", + ) + + +export_json_action_dict: ActionDict = { + "scope": ActionsScope.BULK, + "generate_file": True, + "execute": export_customers_json, +} + + +# ######## Age Operation + + +# dict style +def age_operation_get_value_summary(context: ActionContextSingle): + if not context.has_field_changed("Kind of operation") and not context.has_field_changed("Value"): + return context.form_values.get("summary") + sentence = "add " if context.form_values.get("Kind of operation", "") == "+" else "minus " + sentence += str(context.form_values.get("Value", "")) + return sentence + + +async def age_operation_execute( + context: ActionContextSingle, result_builder: ResultBuilder +) -> Union[None, ActionResult]: + operation = add + if context.form_values["Kind of operation"] == "-": + operation = sub + value = context.form_values["Value"] + + record = await context.get_record(Projection("age")) + await context.collection.update(context.caller, context.filter, {"age": operation(record["age"], value)}) + return result_builder.success("

Success

", options={"type": "html"}) + + +age_operation_action_dict: ActionDict = { + "scope": ActionsScope.SINGLE, + "execute": age_operation_execute, + "form": [ + { + "type": ActionFieldType.ENUM, + "label": "Kind of operation", + "is_required": True, + "default_value": "+", + "value": "+", + "enum_values": ["+", "-"], + }, + { + "type": ActionFieldType.NUMBER, + "label": "Value", + "default_value": 0, }, + { + "type": ActionFieldType.STRING, + "label": "summary", + "is_required": False, + "is_read_only": True, + "value": age_operation_get_value_summary, + }, + { + "label": "test list", + "type": ActionFieldType.STRING_LIST, + "is_required": lambda context: context.form_values.get("Value", 11) > 10, + "is_read_only": lambda context: context.form_values.get("Value", 11) <= 10, + "if_": lambda context: context.form_values.get("Value", 0) > 10, + "default_value": lambda context: [1, 2], + }, + {"label": "Rating", "type": ActionFieldType.ENUM, "enum_values": ["1", "2", "3", "4", "5"]}, + { + "label": "Put a comment", + "type": ActionFieldType.STRING, + # Only display this field if the rating is 4 or 5 + "if_": lambda context: int(context.form_values.get("Rating", "0") or "0") < 4, + }, + {"label": "test filelist", "type": ActionFieldType.FILE_LIST, "is_required": False, "default_value": []}, + ], +} + + +# # charts +async def total_orders_customer_chart( + context: CollectionChartContext, result_builder: ResultBuilderChart, ids: CompositeIdAlias +): + orders = await context.datasource.get_collection("order").aggregate( + caller=context.caller, + filter_=Filter({"condition_tree": ConditionTreeLeaf("customer_id", Operator.EQUAL, ids[0])}), + aggregation=Aggregation({"field": "amount", "operation": "Sum"}), ) - rows = await context.datasource.get_collection("Order").aggregate(context.caller, condition, aggregation) - # rows = await context.datasource.get_collection("order").aggregate(context.caller, condition, aggregation) - ret = [] - for record in records: - filtered = [*filter(lambda r: r["group"]["customer_id"] == record["id"], rows)] - row = filtered[0] if len(filtered) > 0 else {} - ret.append(row.get("value", 0)) + return result_builder.value(orders[0]["value"]) + + +async def order_details(context: CollectionChartContext, result_builder: ResultBuilderChart, ids: CompositeIdAlias): + orders = await context.datasource.get_collection("order").list( + context.caller, + PaginatedFilter( + {"condition_tree": ConditionTreeLeaf("customer_id", Operator.IN, ids)}, + ), + Projection("id", "customer_full_name"), + ) + return result_builder.smart(orders) + + +# hooks + + +def hook_customer_before_create(context: HookBeforeCreateContext): + for data in context.data: + if data.get("last_name", "").lower() == "norris" and data.get("first_name", "").lower() == "chuck": + context.throw_forbidden_error("You can't hit Chuck Norris; even on a keyboard !!!") + + +async def hook_customer_after_list(context: HookAfterListContext): + logger = logging.getLogger("forestadmin") + if context.filter.condition_tree is not None: + logger.info("you're looking for someone ??") - return ret + if len(context.records) > 0: + logger.info("All these customers, you must be rich !!!") + else: + logger.info("No customers, No problems !!!") diff --git a/src/_example/django/django_demo/app/forest/order.py b/src/_example/django/django_demo/app/forest/order.py new file mode 100644 index 000000000..085ad29cc --- /dev/null +++ b/src/_example/django/django_demo/app/forest/order.py @@ -0,0 +1,166 @@ +import io +import json +from typing import List, Union + +from demo.models.models import ORDER_STATUS +from forestadmin.datasource_toolkit.context.agent_context import AgentCustomizationContext +from forestadmin.datasource_toolkit.context.collection_context import CollectionCustomizationContext +from forestadmin.datasource_toolkit.decorators.action.context.base import ActionContext +from forestadmin.datasource_toolkit.decorators.action.result_builder import ResultBuilder +from forestadmin.datasource_toolkit.decorators.action.types.actions import ActionDict +from forestadmin.datasource_toolkit.decorators.chart.result_builder import ResultBuilder as ResultBuilderChart +from forestadmin.datasource_toolkit.decorators.computed.types import ComputedDefinition +from forestadmin.datasource_toolkit.interfaces.actions import ActionFieldType, ActionResult, ActionsScope +from forestadmin.datasource_toolkit.interfaces.fields import Operator, PrimitiveType +from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation, DateOperation, PlainAggregation +from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.branch import Aggregator, ConditionTreeBranch +from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.leaf import ConditionTreeLeaf +from forestadmin.datasource_toolkit.interfaces.query.filter.paginated import PaginatedFilter +from forestadmin.datasource_toolkit.interfaces.query.filter.unpaginated import Filter +from forestadmin.datasource_toolkit.interfaces.query.projections import Projection +from forestadmin.datasource_toolkit.interfaces.records import RecordsDataAlias +from sqlalchemy.sql import text + +# segments + + +def pending_order_segment(context: CollectionCustomizationContext): + Session_ = context.collection.get_native_driver() + with Session_() as connection: + rows = connection.execute(text("select id, status from 'app_order' where status = 'PENDING'")).all() + + return ConditionTreeLeaf( + field="id", + operator=Operator.IN, + value=[r[0] for r in rows], + ) + + +def delivered_order_segment(context: CollectionCustomizationContext): + return ConditionTreeLeaf( + field="status", + operator=Operator.EQUAL, + value=ORDER_STATUS.DELIVERED, + ) + + +def dispatched_order_segment(context: CollectionCustomizationContext): + return ConditionTreeLeaf( + field="status", + operator=Operator.EQUAL, + value=ORDER_STATUS.DISPATCHED, + ) + + +def rejected_order_segment(context: CollectionCustomizationContext): + return ConditionTreeLeaf( + field="status", + operator=Operator.EQUAL, + value=ORDER_STATUS.REJECTED, + ) + + +def suspicious_order_segment(context: CollectionCustomizationContext): + too_old = ConditionTreeLeaf(field="customer:age", operator=Operator.GREATER_THAN, value=99) + too_young = ConditionTreeLeaf(field="customer:age", operator=Operator.LESS_THAN, value=18) + return ConditionTreeBranch(Aggregator.OR, [too_old, too_young]) + + +# computed fields +def get_customer_full_name_field(): + async def get_customer_full_name_value(records: List[RecordsDataAlias], context: CollectionCustomizationContext): + return [f"{record['customer']['first_name']} {record['customer']['last_name']}" for record in records] + + return ComputedDefinition( + column_type=PrimitiveType.STRING, + dependencies=["customer:first_name", "customer:last_name"], + get_values=get_customer_full_name_value, + ) + + +# actions +async def execute_export_json(context: ActionContext, result_builder: ResultBuilder) -> Union[None, ActionResult]: + records = await context.get_records( + Projection( + "id", + "customer:full_name", + "billing_address:full_address", + "delivering_address:full_address", + "status", + "amount", + ) + ) + return result_builder.file( + io.BytesIO(json.dumps({"data": records}, default=str).encode("utf-8")), "dumps.json", "application/json" + ) + + +export_orders_json: ActionDict = { + "scope": ActionsScope.GLOBAL, + "generate_file": True, + "execute": execute_export_json, + "form": [ + { + "type": ActionFieldType.STRING, + "label": "dummy field", + "is_required": False, + "description": "", + "default_value": "", + "value": "", + }, + { + "type": ActionFieldType.COLLECTION, + "collection_name": "Customer", + "label": "customer", + "is_required": True, + "description": "", + "default_value": "", + "value": "", + }, + ], +} + + +async def refound_order_execute(context: ActionContext, result_builder: ResultBuilder) -> Union[None, ActionResult]: + return result_builder.success("fake refund") + + +refound_order_action: ActionDict = { + "scope": ActionsScope.BULK, + "execute": refound_order_execute, + "form": [ + { + "type": ActionFieldType.STRING, + "label": "reason", + "is_required": False, + "description": "", + "default_value": "", + "value": "", + }, + ], +} + + +# charts +async def total_order_chart(context: AgentCustomizationContext, result_builder: ResultBuilderChart): + records = await context.datasource.get_collection("order").list( + context.caller, PaginatedFilter({}), Projection("id") + ) + return result_builder.value(len(records)) + + +async def nb_order_per_week(context: AgentCustomizationContext, result_builder: ResultBuilderChart): + records = await context.datasource.get_collection("order").aggregate( + context.caller, + Filter({"condition_tree": ConditionTreeLeaf("created_at", Operator.BEFORE, "2022-01-01")}), + Aggregation( + PlainAggregation( + field="created_at", + operation="Count", + groups=[{"field": "created_at", "operation": DateOperation.WEEK}], + ) + ), + ) + return result_builder.time_based( + DateOperation.WEEK, {entry["group"]["created_at"]: entry["value"] for entry in records} + ) diff --git a/src/_example/django/django_demo/app/forest_admin.py b/src/_example/django/django_demo/app/forest_admin.py index b154a126c..a43e51730 100644 --- a/src/_example/django/django_demo/app/forest_admin.py +++ b/src/_example/django/django_demo/app/forest_admin.py @@ -1,25 +1,221 @@ -from app.forest.customer import get_customer_spent_values -from forestadmin.datasource_toolkit.interfaces.fields import PrimitiveType +import datetime + +from app.forest.address import address_full_name_computed, get_postal_code, high_delivery_address_segment +from app.forest.cart import cart_update_name +from app.forest.customer import ( + age_operation_action_dict, + customer_full_name, + customer_full_name_write, + customer_spending_computed, + export_json_action_dict, + french_address_segment, + full_name_contains, + full_name_equal, + full_name_greater_than, + full_name_in, + full_name_less_than, + full_name_like, + full_name_not_contains, + full_name_not_in, + hook_customer_after_list, + hook_customer_before_create, + order_details, + total_orders_customer_chart, +) +from app.forest.order import ( + delivered_order_segment, + dispatched_order_segment, + export_orders_json, + get_customer_full_name_field, + nb_order_per_week, + pending_order_segment, + refound_order_action, + rejected_order_segment, + suspicious_order_segment, + total_order_chart, +) +from forestadmin.datasource_toolkit.decorators.computed.types import ComputedDefinition +from forestadmin.datasource_toolkit.interfaces.fields import Operator, PrimitiveType +from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.leaf import ConditionTreeLeaf from forestadmin.django_agent.agent import DjangoAgent def customize_forest(agent: DjangoAgent): + # customize_forest_logging() + + # # ## ADDRESS + agent.customize_collection("Address").add_segment("highOrderDelivery", high_delivery_address_segment).rename_field( + "country", "pays" + ).add_field("full_address", address_full_name_computed("country")).rename_field( + "full_address", "complete_address" + ).replace_field_sorting( + "full_address", + [ + {"field": "country", "ascending": True}, + {"field": "city", "ascending": True}, + {"field": "street", "ascending": True}, + ], + ).remove_field( + # changing visibility + "number" + # deactivate count + ).disable_count().add_external_relation( + "postal_code", + { + "schema": { + "codePostal": PrimitiveType.STRING, + "codeCommune": PrimitiveType.STRING, + "nomCommune": PrimitiveType.STRING, + "libelleAcheminement": PrimitiveType.STRING, + }, + "list_records": get_postal_code, + "dependencies": ["zip_code"], + }, + ) + + # cart agent.customize_collection("Cart").add_field( "customer_id", + ComputedDefinition( + column_type=PrimitiveType.NUMBER, + dependencies=["order:customer_id"], + get_values=lambda records, context: [rec["order"]["customer_id"] for rec in records], + ), + ).add_field( + "customer_id", + ComputedDefinition( + column_type=PrimitiveType.NUMBER, + dependencies=["order:customer_id"], + get_values=lambda records, context: [rec["order"]["customer_id"] for rec in records], + ), + ).emulate_field_operator( + "customer_id", Operator.IN + ).replace_field_writing( + "name", cart_update_name + ).add_segment( + "No order", lambda ctx: ConditionTreeLeaf("order_id", Operator.EQUAL, None) + ) + + # # ## CUSTOMERS + # # import field ? + agent.customize_collection("Customer").add_field( + "age", { - "dependencies": ["order:customer_id"], "column_type": PrimitiveType.NUMBER, - "get_values": lambda records, context: [rec["order"]["customer_id"] for rec in records], + "dependencies": ["birthday_date"], + "get_values": lambda records, ctx: [ + int((datetime.date.today() - r["birthday_date"]).days / 365) for r in records + ], }, - ).emulate_field_filtering("customer_id") - agent.customize_collection("Customer").add_field( - "total_spending", - {"column_type": PrimitiveType.NUMBER, "dependencies": ["id"], "get_values": get_customer_spent_values}, - ).add_one_to_many_relation("smart_carts", "Cart", "customer_id").add_many_to_many_relation( + ).add_segment("with french address", french_address_segment).add_segment( + "VIP customers", + lambda context: ConditionTreeLeaf("is_vip", Operator.EQUAL, True) + # add actions + ).add_action( + "Export json", export_json_action_dict + ).add_action( + "Age operation", age_operation_action_dict + ).add_field( + # # computed + "full_name", + customer_full_name(), + ).replace_field_writing( + # custom write on computed + "full_name", + customer_full_name_write, + ).replace_field_operator( + # custom operators for computed fields + "full_name", + Operator.EQUAL, + full_name_equal, + ).replace_field_operator( + "full_name", Operator.IN, full_name_in + ).replace_field_operator( + "full_name", Operator.NOT_IN, full_name_not_in + ).replace_field_operator( + "full_name", Operator.LESS_THAN, full_name_less_than + ).replace_field_operator( + "full_name", Operator.GREATER_THAN, full_name_greater_than + ).replace_field_operator( + "full_name", Operator.LIKE, full_name_like + ).replace_field_operator( + "full_name", Operator.CONTAINS, full_name_contains + ).replace_field_operator( + "full_name", Operator.NOT_CONTAINS, full_name_not_contains + ).emulate_field_filtering( + # emulate others operators + "full_name" + ).add_field( + "TotalSpending", + customer_spending_computed() + # validation + # ).add_field_validation( + # "age", Operator.GREATER_THAN, 0 + ).add_chart( + # charts + "total_orders", + total_orders_customer_chart, + ).add_chart( + "orders_table", order_details + ).add_many_to_many_relation( # relations "smart_billing_addresses", "Address", "Order", "customer_id", "billing_address_id", + ).add_many_to_many_relation( + "smart_delivering_addresses", "Address", "Order", "customer_id", "delivering_address_id" + ).add_one_to_many_relation( + "smart_carts", "Cart", "customer_id" + ).add_hook( + # hooks + "Before", + "Create", + hook_customer_before_create, + ).add_hook( + "After", "List", hook_customer_after_list ) + + # # ## ORDERS + agent.customize_collection("Order").add_segment("Pending order", pending_order_segment).add_segment( + # segment + "Delivered order", + delivered_order_segment, + ).add_segment("Rejected order", rejected_order_segment).add_segment( + "Dispatched order", dispatched_order_segment + ).add_segment( + "Suspicious order", suspicious_order_segment + ).add_segment( + "newly_created", lambda context: ConditionTreeLeaf("created_at", Operator.AFTER, "2023-01-01") + ).rename_field( + # rename + "amount", + "cost", + ).add_action( + # action + "Export json", + export_orders_json, + ).add_action( + "Refund order(s)", refound_order_action + ).add_field_validation( + # validation + "amount", + Operator.GREATER_THAN, + 0, + ).add_field( + # # computed + "customer_full_name", + get_customer_full_name_field(), + ).import_field( + "customer_first_name", {"path": "customer:first_name"} + ) + + # general + agent.add_chart("total_order", total_order_chart).add_chart( + "mytablechart", + lambda ctx, result_builder: result_builder.smart( + [{"username": "Darth Vador", "points": 1500000}, {"username": "Luke Skywalker", "points": 2}] + ), + ).add_chart("total_order_week", nb_order_per_week) + return agent diff --git a/src/_example/django/django_demo/app/management/commands/populate-db.py b/src/_example/django/django_demo/app/management/commands/populate-db.py index 72696f9bf..87bb4c9e4 100644 --- a/src/_example/django/django_demo/app/management/commands/populate-db.py +++ b/src/_example/django/django_demo/app/management/commands/populate-db.py @@ -1,7 +1,7 @@ import random from datetime import datetime, timezone -from app.models import Address, Cart, Customer, Order +from app.models import Address, Cart, Customer, CustomerAddress, Order from django.contrib.auth.models import Group, User from django.core.management.base import BaseCommand from faker import Faker @@ -12,14 +12,46 @@ class Command(BaseCommand): help = "Create fake data for the database" - # def add_arguments(self, parser): - # parser.add_argument("poll_ids", nargs="+", type=int) + def add_arguments(self, parser): + parser.add_argument( + "-b", + "--big-data", + help=f"create a lot of data, can take a while(~=5min)). Open this file ({__file__}) to edit the values", + action="store_true", + ) def handle(self, *args, **options): - users, groups = create_users_groups() - customers = create_customers() - addresses = create_addresses(customers) - orders, carts = create_orders_cart(customers, addresses) + numbers = { + "groups": 4, + "users": 10, + "customers": 500, + "addresses": 500, + "orders_carts": 1000, + } + if options["big_data"]: + numbers = { + "groups": 50, + "users": 1000, + "customers": 500000, + "addresses": 1000000, + "orders_carts": 3000000, + } + + users, groups = create_users_groups(numbers["groups"], numbers["users"]) + if options["verbosity"] != 0: + print(f"users({numbers['users']}) and groups({numbers['groups']}) created ") + + customers = create_customers(numbers["customers"]) + if options["verbosity"] != 0: + print(f"customers({numbers['customers']}) created") + + addresses = create_addresses(customers, numbers["addresses"]) + if options["verbosity"] != 0: + print(f"addresses({numbers['addresses']}) created") + + orders, carts = create_orders_cart(customers, addresses, numbers["orders_carts"]) + if options["verbosity"] != 0: + print(f"orders and carts ({numbers['orders_carts']}) created") def create_users_groups(nb_group=4, nb_users=10): @@ -27,16 +59,26 @@ def create_users_groups(nb_group=4, nb_users=10): users = [] for i in range(nb_group): g = Group(name=fake.company()) - g.save() - g.refresh_from_db() groups.append(g) + Group.objects.bulk_create(groups) + groups = Group.objects.all() + usernames = set() for i in range(nb_users): - u = User(username=f"{fake.first_name()[0]}{fake.last_name()}") - u.save() - u.refresh_from_db() - u.groups.add(groups[i % (len(groups) - 1)]) + uname = f"{fake.first_name()[0]}{fake.last_name()}" + while uname in usernames: + uname = f"{fake.first_name()[0]}{fake.last_name()}" + usernames.add(uname) + u = User(username=uname) users.append(u) + User.objects.bulk_create(users) + users = User.objects.all() + + groups_users = [] + for i in range(1, nb_users): + groups_users.append(User.groups.through(user_id=i, group_id=(i % nb_group) + 1)) + User.groups.through.objects.bulk_create(groups_users) + return users, groups @@ -44,9 +86,9 @@ def create_customers(nb_customers=500): customers = [] for i in range(nb_customers): c = Customer(first_name=fake.first_name(), last_name=fake.last_name(), birthday_date=fake.date_of_birth()) - c.save() - c.refresh_from_db() customers.append(c) + Customer.objects.bulk_create(customers) + customers = Customer.objects.all() return customers @@ -60,10 +102,16 @@ def create_addresses(customers, nb_addresses=500): country=fake.country(), zip_code=fake.postcode(), ) - a.save() - a.refresh_from_db() - a.customers.add(customers[i % (len(customers)) - 1]) addresses.append(a) + Address.objects.bulk_create(addresses) + addresses = Address.objects.all() + + customer_addresses = [] + nb_customer = customers.count() + for i in range(1, nb_addresses): + customer_addresses.append(CustomerAddress(address_id=i, customer_id=((i - 1) % nb_customer) + 1)) + CustomerAddress.objects.bulk_create(customer_addresses) + return addresses @@ -71,8 +119,9 @@ def create_orders_cart(customers, addresses, nb_order=1000): orders = [] carts = [] - for i in range(nb_order): + for i in range(1, nb_order + 1): o = Order( + id=i, ordered_at=fake.date_time_between(start_date=datetime(2015, 1, 1), tzinfo=timezone.utc), amount=random.randint(0, 100000) / 100, customer=customers[i % (len(customers) - 1)], @@ -80,12 +129,13 @@ def create_orders_cart(customers, addresses, nb_order=1000): delivering_address=addresses[i % (len(addresses) - 1)], status=random.choice(list(Order.OrderStatus)), ) - o.save() - o.refresh_from_db() orders.append(o) + Order.objects.bulk_create(orders) + orders = Order.objects.all() - c = Cart(name=fake.city(), order=o) - c.save() - c.refresh_from_db() + for i in range(1, nb_order + 1): + c = Cart(name=fake.city(), order_id=i) carts.append(c) + Cart.objects.bulk_create(carts) + carts = Cart.objects.all() return orders, carts From f7ce30421af00ed93508e3453ef6ff0c8f961f1c Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 15:01:28 +0100 Subject: [PATCH 05/19] fix(django_datasource): projection with many_to_one now works --- .../resources/collections/crud.py | 2 +- .../datasource_django/utils/query_factory.py | 30 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/crud.py b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/crud.py index 5025a4211..47900653d 100644 --- a/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/crud.py +++ b/src/agent_toolkit/forestadmin/agent_toolkit/resources/collections/crud.py @@ -51,7 +51,7 @@ from forestadmin.datasource_toolkit.validations.field import FieldValidatorException from forestadmin.datasource_toolkit.validations.records import RecordValidator, RecordValidatorException -LiteralMethod = Literal["list", "count", "add", "delete_list", "csv"] +LiteralMethod = Literal["list", "count", "add", "get", "delete_list", "csv"] class CrudResource(BaseCollectionResource): diff --git a/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py b/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py index d362bb654..1235b583b 100644 --- a/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py +++ b/src/datasource_django/forestadmin/datasource_django/utils/query_factory.py @@ -1,3 +1,4 @@ +from collections import defaultdict from datetime import date, datetime from typing import Any, Dict, List, Optional, Set, Tuple @@ -55,7 +56,11 @@ def _mk_base_queryset( select_related, prefetch_related = DjangoQueryBuilder._find_related_in_projection( collection, largest_projection ) - qs = qs.select_related(*select_related).prefetch_related(*prefetch_related) + qs = qs.select_related( + *cls._normalize_projection(Projection(*select_related)), + ).prefetch_related( + *cls._normalize_projection(Projection(*prefetch_related)), + ) qs = qs.filter(DjangoQueryConditionTreeBuilder.build(filter_.condition_tree)) @@ -71,25 +76,26 @@ def _find_related_in_projection( ) -> Tuple[Set[str], Set[str]]: select_related = set() prefetch_related = set() - break_select_related = False + break_select_related: Dict[str, bool] = defaultdict(lambda: False) - # TODO: Simplify this code, I think we never use a prefetch related with forest for relation_name, subfields in projection.relations.items(): field_schema = collection.schema["fields"][relation_name] - if not break_select_related and (is_many_to_one(field_schema) or is_one_to_one(field_schema)): + if not break_select_related[relation_name] and ( + is_many_to_one(field_schema) or is_one_to_one(field_schema) + ): select_related.add(relation_name) else: - break_select_related = True + break_select_related[relation_name] = True prefetch_related.add(relation_name) sub_select, sub_prefetch = cls._find_related_in_projection( collection.datasource.get_collection(field_schema["foreign_collection"]), subfields ) - if not break_select_related: - sub_select.union(Projection(*sub_select).nest(relation_name)) + if not break_select_related[relation_name]: + select_related = select_related.union(Projection(*sub_select).nest(relation_name)) else: - sub_prefetch.union(Projection(*sub_select).nest(relation_name)) - sub_prefetch.union(Projection(*sub_prefetch).nest(relation_name)) + prefetch_related = prefetch_related.union(Projection(*sub_select).nest(relation_name)) + prefetch_related = prefetch_related.union(Projection(*sub_prefetch).nest(relation_name)) return select_related, prefetch_related @@ -107,7 +113,11 @@ async def mk_list( full_projection = full_projection.union(filter_.sort.projection) qs = cls._mk_base_queryset(collection, full_projection, filter_) - qs = qs.only(*cls._normalize_projection(full_projection)) + # only raise errors when trying to get a field by a to_many relation + # or in other words if we have something to pass to prefetch_related + _, prefetch = cls._find_related_in_projection(collection, full_projection) + if len(prefetch) == 0: + qs = qs.only(*cls._normalize_projection(full_projection)) qs = DjangoQueryPaginationBuilder.paginate_queryset(qs, filter_) return await sync_to_async(list)(qs) From 751c8451d007ac4a00ec66d9520f98fb573c7bc7 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 15:02:21 +0100 Subject: [PATCH 06/19] chore(django_datasource): fix tests details --- src/datasource_django/pyproject.toml | 4 ++-- src/datasource_django/tests/test_django_collection.py | 5 ++--- .../{test_project => test_project_datasource}/__init__.py | 0 .../{test_project => test_project_datasource}/manage.py | 2 +- .../test_app/__init__.py | 0 .../test_app/app.py | 0 .../test_app/fixtures/__init__.py | 0 .../test_app/fixtures/book.json | 0 .../test_app/fixtures/person.json | 0 .../test_app/fixtures/rating.json | 0 .../test_app/migrations/0001_initial.py | 0 .../test_app/migrations/__init__.py | 0 .../test_app/models.py | 0 .../test_project_datasource}/settings.py | 0 .../test_project_datasource}/urls.py | 0 15 files changed, 5 insertions(+), 6 deletions(-) rename src/datasource_django/tests/{test_project => test_project_datasource}/__init__.py (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/manage.py (96%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/__init__.py (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/app.py (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/fixtures/__init__.py (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/fixtures/book.json (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/fixtures/person.json (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/fixtures/rating.json (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/migrations/0001_initial.py (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/migrations/__init__.py (100%) rename src/datasource_django/tests/{test_project => test_project_datasource}/test_app/models.py (100%) rename src/datasource_django/tests/{test_project/test_project => test_project_datasource/test_project_datasource}/settings.py (100%) rename src/datasource_django/tests/{test_project/test_project => test_project_datasource/test_project_datasource}/urls.py (100%) diff --git a/src/datasource_django/pyproject.toml b/src/datasource_django/pyproject.toml index ded3343b2..1b23094e1 100644 --- a/src/datasource_django/pyproject.toml +++ b/src/datasource_django/pyproject.toml @@ -69,5 +69,5 @@ path = "../agent_toolkit" develop = true [tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "tests.test_project.test_project.settings" -pythonpath = "tests/test_project" +DJANGO_SETTINGS_MODULE = "test_project_datasource.settings" +pythonpath = "tests/test_project_datasource" diff --git a/src/datasource_django/tests/test_django_collection.py b/src/datasource_django/tests/test_django_collection.py index 009e992e0..7536bbc89 100644 --- a/src/datasource_django/tests/test_django_collection.py +++ b/src/datasource_django/tests/test_django_collection.py @@ -2,9 +2,6 @@ import sys from unittest.mock import Mock, patch -from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation, DateOperation -from forestadmin.datasource_toolkit.interfaces.query.filter.unpaginated import Filter - if sys.version_info >= (3, 9): import zoneinfo else: @@ -15,6 +12,7 @@ from forestadmin.datasource_django.collection import DjangoCollection from forestadmin.datasource_django.datasource import DjangoDatasource from forestadmin.datasource_toolkit.interfaces.fields import Operator +from forestadmin.datasource_toolkit.interfaces.query.aggregation import Aggregation, DateOperation from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.branch import ( Aggregator as ConditionTreeAggregator, ) @@ -23,6 +21,7 @@ ConditionTreeLeaf, ) from forestadmin.datasource_toolkit.interfaces.query.filter.paginated import PaginatedFilter +from forestadmin.datasource_toolkit.interfaces.query.filter.unpaginated import Filter from forestadmin.datasource_toolkit.interfaces.query.page import Page from forestadmin.datasource_toolkit.interfaces.query.projections import Projection from forestadmin.datasource_toolkit.interfaces.query.sort import Sort diff --git a/src/datasource_django/tests/test_project/__init__.py b/src/datasource_django/tests/test_project_datasource/__init__.py similarity index 100% rename from src/datasource_django/tests/test_project/__init__.py rename to src/datasource_django/tests/test_project_datasource/__init__.py diff --git a/src/datasource_django/tests/test_project/manage.py b/src/datasource_django/tests/test_project_datasource/manage.py similarity index 96% rename from src/datasource_django/tests/test_project/manage.py rename to src/datasource_django/tests/test_project_datasource/manage.py index 6e5aada76..0d22e2bc3 100755 --- a/src/datasource_django/tests/test_project/manage.py +++ b/src/datasource_django/tests/test_project_datasource/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project_datasource.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/src/datasource_django/tests/test_project/test_app/__init__.py b/src/datasource_django/tests/test_project_datasource/test_app/__init__.py similarity index 100% rename from src/datasource_django/tests/test_project/test_app/__init__.py rename to src/datasource_django/tests/test_project_datasource/test_app/__init__.py diff --git a/src/datasource_django/tests/test_project/test_app/app.py b/src/datasource_django/tests/test_project_datasource/test_app/app.py similarity index 100% rename from src/datasource_django/tests/test_project/test_app/app.py rename to src/datasource_django/tests/test_project_datasource/test_app/app.py diff --git a/src/datasource_django/tests/test_project/test_app/fixtures/__init__.py b/src/datasource_django/tests/test_project_datasource/test_app/fixtures/__init__.py similarity index 100% rename from src/datasource_django/tests/test_project/test_app/fixtures/__init__.py rename to src/datasource_django/tests/test_project_datasource/test_app/fixtures/__init__.py diff --git a/src/datasource_django/tests/test_project/test_app/fixtures/book.json b/src/datasource_django/tests/test_project_datasource/test_app/fixtures/book.json similarity index 100% rename from src/datasource_django/tests/test_project/test_app/fixtures/book.json rename to src/datasource_django/tests/test_project_datasource/test_app/fixtures/book.json diff --git a/src/datasource_django/tests/test_project/test_app/fixtures/person.json b/src/datasource_django/tests/test_project_datasource/test_app/fixtures/person.json similarity index 100% rename from src/datasource_django/tests/test_project/test_app/fixtures/person.json rename to src/datasource_django/tests/test_project_datasource/test_app/fixtures/person.json diff --git a/src/datasource_django/tests/test_project/test_app/fixtures/rating.json b/src/datasource_django/tests/test_project_datasource/test_app/fixtures/rating.json similarity index 100% rename from src/datasource_django/tests/test_project/test_app/fixtures/rating.json rename to src/datasource_django/tests/test_project_datasource/test_app/fixtures/rating.json diff --git a/src/datasource_django/tests/test_project/test_app/migrations/0001_initial.py b/src/datasource_django/tests/test_project_datasource/test_app/migrations/0001_initial.py similarity index 100% rename from src/datasource_django/tests/test_project/test_app/migrations/0001_initial.py rename to src/datasource_django/tests/test_project_datasource/test_app/migrations/0001_initial.py diff --git a/src/datasource_django/tests/test_project/test_app/migrations/__init__.py b/src/datasource_django/tests/test_project_datasource/test_app/migrations/__init__.py similarity index 100% rename from src/datasource_django/tests/test_project/test_app/migrations/__init__.py rename to src/datasource_django/tests/test_project_datasource/test_app/migrations/__init__.py diff --git a/src/datasource_django/tests/test_project/test_app/models.py b/src/datasource_django/tests/test_project_datasource/test_app/models.py similarity index 100% rename from src/datasource_django/tests/test_project/test_app/models.py rename to src/datasource_django/tests/test_project_datasource/test_app/models.py diff --git a/src/datasource_django/tests/test_project/test_project/settings.py b/src/datasource_django/tests/test_project_datasource/test_project_datasource/settings.py similarity index 100% rename from src/datasource_django/tests/test_project/test_project/settings.py rename to src/datasource_django/tests/test_project_datasource/test_project_datasource/settings.py diff --git a/src/datasource_django/tests/test_project/test_project/urls.py b/src/datasource_django/tests/test_project_datasource/test_project_datasource/urls.py similarity index 100% rename from src/datasource_django/tests/test_project/test_project/urls.py rename to src/datasource_django/tests/test_project_datasource/test_project_datasource/urls.py From 766e1f1514f5c0334abda585698dc8e067928336 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 15:02:58 +0100 Subject: [PATCH 07/19] feat(django_agent): handle file http response --- .../forestadmin/django_agent/utils/converter.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/django_agent/forestadmin/django_agent/utils/converter.py b/src/django_agent/forestadmin/django_agent/utils/converter.py index 84e22c0a4..f99eec4d8 100644 --- a/src/django_agent/forestadmin/django_agent/utils/converter.py +++ b/src/django_agent/forestadmin/django_agent/utils/converter.py @@ -23,7 +23,13 @@ def convert_request(django_request: DjangoRequest, path_params: Dict[str, str] = def convert_response(response: Response) -> DjangoResponse: if isinstance(response, FileResponse): - pass + return DjangoResponse( + response.file, + headers={ + "Content-Type": response.mimetype, + "Content-Disposition": f"attachment; filename={response.name}", + }, + ) else: return DjangoResponse( response.body, From 44f38a7173fb0f28f5edaa0c4e0ec52907b7a2a3 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 15:03:45 +0100 Subject: [PATCH 08/19] fix(django_agent): handle action routes correctly --- .../forestadmin/django_agent/urls.py | 20 ++++++++++++++++--- .../forestadmin/django_agent/views/actions.py | 6 +++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/django_agent/forestadmin/django_agent/urls.py b/src/django_agent/forestadmin/django_agent/urls.py index 2877406a3..146f21d28 100644 --- a/src/django_agent/forestadmin/django_agent/urls.py +++ b/src/django_agent/forestadmin/django_agent/urls.py @@ -9,6 +9,8 @@ prefix = getattr(settings, "FOREST_PREFIX", "") if len(prefix) > 0 and prefix[-1] != "/": prefix = f"{prefix}/" +if len(prefix) > 0 and prefix[0] == "/": + prefix = f"{prefix[1:]}" urlpatterns = [ # generic @@ -18,9 +20,21 @@ path(f"{prefix}forest/authentication", authentication.authentication, name="authentication"), path(f"{prefix}forest/authentication/callback", authentication.callback, name="authentication_callback"), # actions - path(f"{prefix}forest/_actions///hooks/load", actions.hook, name="action_hook_load"), - path(f"{prefix}forest/_actions///hooks/change", actions.hook, name="action_hook_change"), - path(f"{prefix}forest/_actions//", actions.execute, name="action_execute"), + path( + f"{prefix}forest/_actions////hooks/load", + actions.hook, + name="action_hook_load", + ), + path( + f"{prefix}forest/_actions////hooks/change", + actions.hook, + name="action_hook_change", + ), + path( + f"{prefix}forest/_actions///", + actions.execute, + name="action_execute", + ), # charts path(f"{prefix}forest/_charts/", charts.chart_datasource, name="datasource_chart"), path( diff --git a/src/django_agent/forestadmin/django_agent/views/actions.py b/src/django_agent/forestadmin/django_agent/views/actions.py index 54748d8a4..36ae32d90 100644 --- a/src/django_agent/forestadmin/django_agent/views/actions.py +++ b/src/django_agent/forestadmin/django_agent/views/actions.py @@ -5,7 +5,7 @@ async def hook(request: HttpRequest, **kwargs): resource = (await DjangoAgentApp.get_agent().get_resources())["actions"] - response = await resource.dispatch(convert_request(request, kwargs), "execute") + response = await resource.dispatch(convert_request(request, kwargs), "hook") return convert_response(response) @@ -16,5 +16,5 @@ async def execute(request: HttpRequest, **kwargs): # This is so ugly... But django.views.decorators.csrf.csrf_exempt is not asyncio ready -# hook.csrf_exempt = True -# execute.csrf_exempt = True +hook.csrf_exempt = True +execute.csrf_exempt = True From c130b3ebd2d9685665084df1c1b6816b93f932ee Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 15:04:20 +0100 Subject: [PATCH 09/19] chore(django_agent): make the agent initialization testable --- .../forestadmin/django_agent/agent.py | 4 +- .../forestadmin/django_agent/apps.py | 92 +++++++++++-------- .../forestadmin/django_agent/exception.py | 5 - 3 files changed, 58 insertions(+), 43 deletions(-) delete mode 100644 src/django_agent/forestadmin/django_agent/exception.py diff --git a/src/django_agent/forestadmin/django_agent/agent.py b/src/django_agent/forestadmin/django_agent/agent.py index f6cd31641..2eac60166 100644 --- a/src/django_agent/forestadmin/django_agent/agent.py +++ b/src/django_agent/forestadmin/django_agent/agent.py @@ -28,8 +28,8 @@ def __init__(self, config: Optional[Options] = None): config = config if config is not None else self.__parse_config() super(DjangoAgent, self).__init__(config) - def __parse_config(self): - if hasattr(settings, "BASE_DIR"): + def __parse_config(self) -> Options: + if getattr(settings, "BASE_DIR", None) is not None: base_dir = settings.BASE_DIR else: setting_file = importlib.import_module(os.environ[DJANGO_SETTING_MODULE_ENV_VAR_NAME]).__file__ diff --git a/src/django_agent/forestadmin/django_agent/apps.py b/src/django_agent/forestadmin/django_agent/apps.py index c0392114d..f51561548 100644 --- a/src/django_agent/forestadmin/django_agent/apps.py +++ b/src/django_agent/forestadmin/django_agent/apps.py @@ -1,7 +1,7 @@ import asyncio import importlib import sys -from typing import Callable, Union +from typing import Callable, Optional, Union from django.apps import AppConfig from django.conf import settings @@ -11,11 +11,49 @@ def is_launch_as_server() -> bool: - is_manage_py = any(arg.casefold().endswith("manage.py") for arg in sys.argv) + is_manage_py = any(arg.casefold().endswith("manage.py") or arg.casefold().endswith("pytest") for arg in sys.argv) is_runserver = any(arg.casefold() == "runserver" for arg in sys.argv) return (is_manage_py and is_runserver) or (not is_manage_py) +def init_app_agent() -> Optional[DjangoAgent]: + agent = create_agent() + if not getattr(settings, "FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE", None): + agent.add_datasource(DjangoDatasource()) + + customize_fn = getattr(settings, "FOREST_CUSTOMIZE_FUNCTION", None) + if customize_fn: + agent = _call_user_customize_function(customize_fn, agent) + + if agent and is_launch_as_server(): + agent.start() + return agent + + +def _call_user_customize_function(customize_fn: Union[str, Callable[[DjangoAgent], None]], agent: DjangoAgent): + if isinstance(customize_fn, str): + try: + module_name, fn_name = customize_fn.rsplit(".", 1) + module = importlib.import_module(module_name) + customize_fn = getattr(module, fn_name) + except Exception as exc: + ForestLogger.log("error", f"cannot import {customize_fn} : {exc}. Quitting forest.") + return + + if callable(customize_fn): + try: + ret = customize_fn(agent) + if asyncio.iscoroutine(ret): + agent.loop.run_until_complete(ret) + except Exception as exc: + ForestLogger.log( + "error", + f'error executing "FOREST_CUSTOMIZE_FUNCTION" ({customize_fn}): {exc}. Quitting forest.', + ) + return + return agent + + class DjangoAgentApp(AppConfig): _DJANGO_AGENT: DjangoAgent = None name = "forestadmin.django_agent" @@ -33,37 +71,19 @@ def get_agent(cls) -> DjangoAgent: return cls._DJANGO_AGENT def ready(self): - if is_launch_as_server(): - DjangoAgentApp._DJANGO_AGENT = create_agent() - if not getattr(settings, "FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE", None): - DjangoAgentApp._DJANGO_AGENT.add_datasource(DjangoDatasource()) - - customize_fn = getattr(settings, "FOREST_CUSTOMIZE_FUNCTION", None) - if customize_fn: - self._call_user_customize_function(customize_fn) - DjangoAgentApp._DJANGO_AGENT.start() - - def _call_user_customize_function(self, customize_fn: Union[str, Callable[[DjangoAgent], None]]): - if isinstance(customize_fn, str): - try: - module_name, fn_name = customize_fn.rsplit(".", 1) - module = importlib.import_module(module_name) - customize_fn = getattr(module, fn_name) - except Exception as exc: - ForestLogger.log("error", f"cannot import {customize_fn} : {exc}. Quitting forest.") - DjangoAgentApp._DJANGO_AGENT = None - return - - if callable(customize_fn): - try: - if asyncio.iscoroutinefunction(customize_fn): - DjangoAgentApp._DJANGO_AGENT.loop.run_until_complete(customize_fn(DjangoAgentApp._DJANGO_AGENT)) - else: - customize_fn(DjangoAgentApp._DJANGO_AGENT) - except Exception as exc: - ForestLogger.log( - "error", - f'error executing "FOREST_CUSTOMIZE_FUNCTION" ({customize_fn}): {exc}. Quitting forest.', - ) - DjangoAgentApp._DJANGO_AGENT = None - return + DjangoAgentApp._DJANGO_AGENT = init_app_agent() + + +# from django.utils.autoreload import DJANGO_AUTORELOAD_ENV +# import os +# no_autoreload = any(arg.casefold() == "noreload" for arg in sys.argv) +# django.utils.autoreload.DJANGO_AUTORELOAD_ENV == "RUN_MAIN" +# print( +# "--run main: ", os.environ.get(DJANGO_AUTORELOAD_ENV) +# ) # to know in which process we are when autoreload is enabled +# print("--launch_as_server: ", launch_as_server) +# print("--no_autoreload: ", no_autoreload) + +# prevent launching for manage command +# prevent launching in reloader parent process +# if not is_manage_py or no_autoreload or os.environ.get(DJANGO_AUTORELOAD_ENV) == "true": diff --git a/src/django_agent/forestadmin/django_agent/exception.py b/src/django_agent/forestadmin/django_agent/exception.py deleted file mode 100644 index d2918d7ce..000000000 --- a/src/django_agent/forestadmin/django_agent/exception.py +++ /dev/null @@ -1,5 +0,0 @@ -from forestadmin.datasource_toolkit.exceptions import ForestException - - -class DjangoAgentException(ForestException): - pass From 6f04380bab7885ffc34af77de0a306e0205ac625 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 15:04:56 +0100 Subject: [PATCH 10/19] chore(django_agent): add tests for django agent --- src/agent_toolkit/tests/test_agent_toolkit.py | 4 +- src/django_agent/pyproject.toml | 4 + src/django_agent/tests/__init__.py | 0 src/django_agent/tests/test_agent_creation.py | 208 +++ src/django_agent/tests/test_http_routes.py | 409 +++++ .../.forestadmin-schema.json | 1335 +++++++++++++++++ .../tests/test_project_agent/__init__.py | 0 .../tests/test_project_agent/manage.py | 22 + .../test_project_agent/test_app/__init__.py | 0 .../tests/test_project_agent/test_app/app.py | 6 + .../test_app/fixtures/__init__.py | 0 .../test_app/fixtures/book.json | 12 + .../test_app/fixtures/person.json | 20 + .../test_app/fixtures/rating.json | 57 + .../test_app/forest_admin.py | 5 + .../test_app/migrations/0001_initial.py | 47 + .../test_app/migrations/__init__.py | 0 .../test_project_agent/test_app/models.py | 27 + .../test_project_agent/settings.py | 32 + .../test_project_agent/urls.py | 5 + 20 files changed, 2191 insertions(+), 2 deletions(-) create mode 100644 src/django_agent/tests/__init__.py create mode 100644 src/django_agent/tests/test_agent_creation.py create mode 100644 src/django_agent/tests/test_http_routes.py create mode 100644 src/django_agent/tests/test_project_agent/.forestadmin-schema.json create mode 100644 src/django_agent/tests/test_project_agent/__init__.py create mode 100755 src/django_agent/tests/test_project_agent/manage.py create mode 100644 src/django_agent/tests/test_project_agent/test_app/__init__.py create mode 100644 src/django_agent/tests/test_project_agent/test_app/app.py create mode 100644 src/django_agent/tests/test_project_agent/test_app/fixtures/__init__.py create mode 100644 src/django_agent/tests/test_project_agent/test_app/fixtures/book.json create mode 100644 src/django_agent/tests/test_project_agent/test_app/fixtures/person.json create mode 100644 src/django_agent/tests/test_project_agent/test_app/fixtures/rating.json create mode 100644 src/django_agent/tests/test_project_agent/test_app/forest_admin.py create mode 100644 src/django_agent/tests/test_project_agent/test_app/migrations/0001_initial.py create mode 100644 src/django_agent/tests/test_project_agent/test_app/migrations/__init__.py create mode 100644 src/django_agent/tests/test_project_agent/test_app/models.py create mode 100644 src/django_agent/tests/test_project_agent/test_project_agent/settings.py create mode 100644 src/django_agent/tests/test_project_agent/test_project_agent/urls.py diff --git a/src/agent_toolkit/tests/test_agent_toolkit.py b/src/agent_toolkit/tests/test_agent_toolkit.py index 77b2edd57..59946f73f 100644 --- a/src/agent_toolkit/tests/test_agent_toolkit.py +++ b/src/agent_toolkit/tests/test_agent_toolkit.py @@ -243,7 +243,7 @@ def test_start( return_value=agent.customizer.stack.datasource, ): self.loop.run_until_complete(agent._start()) - self.assertEqual(logger.output, ["DEBUG:forestadmin:Starting agent", "INFO:forestadmin:Agent started"]) + self.assertEqual(logger.output, ["DEBUG:forestadmin:Starting agent", "DEBUG:forestadmin:Agent started"]) mocked_create_json_api_schema.assert_called_once_with("fake_collection") mocked_schema_emitter__get_serialized_schema.assert_called_once() @@ -292,7 +292,7 @@ def test_start_dont_crash_if_schema_generation_or_sending_fail( self.assertEqual( logger.output[2], "WARNING:forestadmin:Cannot send the apimap to Forest. Are you online?" ) - self.assertEqual(logger.output[3], "INFO:forestadmin:Agent started") + self.assertEqual(logger.output[3], "DEBUG:forestadmin:Agent started") self.assertEqual(len(logger.output), 4) mocked_forest_http_api__send_schema.assert_not_awaited() diff --git a/src/django_agent/pyproject.toml b/src/django_agent/pyproject.toml index f1b74c5ff..c1eb972d6 100644 --- a/src/django_agent/pyproject.toml +++ b/src/django_agent/pyproject.toml @@ -65,3 +65,7 @@ develop = true [tool.poetry.group.test.dependencies.forestadmin-agent-toolkit] path = "../agent_toolkit" develop = true + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "test_project_agent.settings" +pythonpath = "tests/test_project_agent" diff --git a/src/django_agent/tests/__init__.py b/src/django_agent/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/django_agent/tests/test_agent_creation.py b/src/django_agent/tests/test_agent_creation.py new file mode 100644 index 000000000..531d61a93 --- /dev/null +++ b/src/django_agent/tests/test_agent_creation.py @@ -0,0 +1,208 @@ +import os +from unittest import TestCase +from unittest.mock import AsyncMock, Mock, patch + +from django.test import TestCase as DjangoTestCase +from django.test import override_settings +from forestadmin.django_agent.agent import DjangoAgent, create_agent +from forestadmin.django_agent.apps import init_app_agent, is_launch_as_server + + +class TestDjangoAgentCreation(DjangoTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.dj_options = { + "FOREST_ENV_SECRET": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "FOREST_AUTH_SECRET": "de1s5LAbFFAPRvCJQTLb", + "FOREST_CUSTOMIZE_FUNCTION": lambda agent: None, + "FOREST_LOGGER": lambda level, msg: None, + } + + def test_init_should_parse_settings(self): + with override_settings(**self.dj_options): + agent: DjangoAgent = DjangoAgent() + self.assertEqual(agent.options["auth_secret"], "de1s5LAbFFAPRvCJQTLb") + self.assertEqual( + agent.options["env_secret"], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ) + self.assertEqual( + agent.options["schema_path"], + os.path.abspath(os.path.join(__file__, "..", "test_project_agent", ".forestadmin-schema.json")), + ) + + def test_init_should_compute_schema_path_with_and_without_base_dir_setting(self): + with override_settings( + **self.dj_options, **{"BASE_DIR": os.path.abspath(os.path.join(__file__, "..", "test_project_agent"))} + ): + agent: DjangoAgent = DjangoAgent() + self.assertEqual( + agent.options["schema_path"], + os.path.abspath(os.path.join(__file__, "..", "test_project_agent", ".forestadmin-schema.json")), + ) + + with override_settings(**self.dj_options): + agent: DjangoAgent = DjangoAgent() + self.assertEqual( + agent.options["schema_path"], + os.path.abspath(os.path.join(__file__, "..", "test_project_agent", ".forestadmin-schema.json")), + ) + + def test_create_agent_should_create_agent_with_django_settings(self): + with override_settings(**self.dj_options): + agent: DjangoAgent = create_agent() + + self.assertEqual(agent.options["auth_secret"], "de1s5LAbFFAPRvCJQTLb") + self.assertEqual( + agent.options["env_secret"], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ) + self.assertEqual( + agent.options["schema_path"], + os.path.abspath(os.path.join(__file__, "..", "test_project_agent", ".forestadmin-schema.json")), + ) + + def test_create_agent_should_create_agent_with_given_settings(self): + agent: DjangoAgent = create_agent( + { + "auth_secret": "11111111111111111111", + "env_secret": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "schema_path": "./.forestadmin-schema.json", + } + ) + + self.assertEqual(agent.options["auth_secret"], "11111111111111111111") + self.assertEqual( + agent.options["env_secret"], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ) + self.assertEqual(agent.options["schema_path"], "./.forestadmin-schema.json") + + def test_agent_start_should_call_base_agent_start(self): + agent: DjangoAgent = create_agent() + + with patch.object(agent, "_start", new_callable=AsyncMock) as mock_base_start: + agent.start() + mock_base_start.assert_awaited_once() + + +class TestDjangoAgentLaunchAsServer(TestCase): + def test_is_launch_as_server_should_return_False_on_pytest_and_other_no_runserver_manage_command(self): + with patch("forestadmin.django_agent.apps.sys.argv", ["manage.py", "migrate"]): + self.assertFalse(is_launch_as_server()) + + with patch("forestadmin.django_agent.apps.sys.argv", ["pytest"]): + self.assertFalse(is_launch_as_server()) + + def test_is_launch_as_server_should_return_True_on_no_pytest_and_other_runserver_manage_command(self): + with patch("forestadmin.django_agent.apps.sys.argv", ["manage.py", "runserver"]): + self.assertTrue(is_launch_as_server()) + + with patch("forestadmin.django_agent.apps.sys.argv", []): + self.assertTrue(is_launch_as_server()) + + +class TestDjangoAgentInitAppAgent(DjangoTestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.dj_options = { + "FOREST_ENV_SECRET": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "FOREST_AUTH_SECRET": "de1s5LAbFFAPRvCJQTLb", + "FOREST_CUSTOMIZE_FUNCTION": lambda agent: None, + "FOREST_LOGGER": lambda level, msg: None, + } + + def test_should_add_datasource_when_no_setting(self): + with override_settings(**self.dj_options): + with patch( + "forestadmin.django_agent.apps.DjangoDatasource", return_value="dj_datasource" + ) as mock_django_datasource: + with patch.object(DjangoAgent, "add_datasource") as mock_add_datasource: + init_app_agent() + mock_add_datasource.assert_called_once_with("dj_datasource") + mock_django_datasource.assert_called_once() + + def test_should_add_datasource_not_called_when_no_auto_added_asked(self): + with override_settings(**self.dj_options, FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE=True): + with patch("forestadmin.django_agent.apps.DjangoDatasource") as mock_django_datasource: + with patch.object(DjangoAgent, "add_datasource") as mock_add_datasource: + init_app_agent() + mock_add_datasource.assert_not_called() + mock_django_datasource.assert_not_called() + + def test_should_call_customize_fn_when_setting_is_function(self): + def customize_fn(agent): + pass + + spy_customize_fn = Mock(customize_fn, wraps=customize_fn) + + with override_settings(**{**self.dj_options, "FOREST_CUSTOMIZE_FUNCTION": spy_customize_fn}): + agent = init_app_agent() + spy_customize_fn.assert_called_once_with(agent) + + def test_should_call_customize_fn_when_setting_is_coroutine(self): + async def customize_fn(agent): + pass + + spy_customize_fn = AsyncMock(customize_fn, wraps=customize_fn) + + with override_settings(**{**self.dj_options, "FOREST_CUSTOMIZE_FUNCTION": spy_customize_fn}): + agent = init_app_agent() + spy_customize_fn.assert_awaited_once_with(agent) + + def test_should_call_customize_fn_and_return_None_when_error_in_customize_fn(self): + customizer_param = {"agent": None} + + def customizer(agent): + customizer_param["agent"] = agent + 1 / 0 + + spy_customize_fn = Mock(wraps=customizer) + + with override_settings(**{**self.dj_options, "FOREST_CUSTOMIZE_FUNCTION": spy_customize_fn}): + agent = init_app_agent() + spy_customize_fn.assert_called_once_with(customizer_param["agent"]) + self.assertIsNone(agent) + + def test_should_call_customize_fn_when_param_is_a_string(self): + def customizer(agent): + pass + + with patch("test_app.forest_admin.customize_agent", wraps=customizer) as mock_customizer: + with override_settings( + **{**self.dj_options, "FOREST_CUSTOMIZE_FUNCTION": "test_app.forest_admin.customize_agent"} + ): + agent = init_app_agent() + mock_customizer.assert_called_once_with(agent) + + def test_should_return_None_when_error_in_customize_fn_import_from_str(self): + with override_settings( + **{**self.dj_options, "FOREST_CUSTOMIZE_FUNCTION": "test_app.forest_admin.customize_agent_import_error"} + ): + agent = init_app_agent() + self.assertIsNone(agent) + + def test_should_call_agent_start_when_everything_work_well_and_launch_as_server(self): + with override_settings(**self.dj_options): + with patch("forestadmin.django_agent.agent.DjangoAgent.start") as mock_start: + with patch("forestadmin.django_agent.apps.is_launch_as_server", return_value=True): + agent = init_app_agent() + mock_start.assert_called_once() + self.assertIsNotNone(agent) + + def test_should_not_call_agent_start_when_everything_work_well_but_not_launch_as_server(self): + with override_settings(**self.dj_options): + with patch("forestadmin.django_agent.agent.DjangoAgent.start") as mock_start: + with patch("forestadmin.django_agent.apps.is_launch_as_server", return_value=False): + agent = init_app_agent() + mock_start.assert_not_called() + self.assertIsNotNone(agent) + + def test_should_not_call_agent_start_when_error_during_customize_fn(self): + with override_settings( + **{**self.dj_options, "FOREST_CUSTOMIZE_FUNCTION": "test_app.forest_admin.customize_agent_import_error"} + ): + with patch("forestadmin.django_agent.agent.DjangoAgent.start") as mock_start: + with patch("forestadmin.django_agent.apps.is_launch_as_server", return_value=True): + agent = init_app_agent() + mock_start.assert_not_called() + self.assertIsNone(agent) diff --git a/src/django_agent/tests/test_http_routes.py b/src/django_agent/tests/test_http_routes.py new file mode 100644 index 000000000..b7709f775 --- /dev/null +++ b/src/django_agent/tests/test_http_routes.py @@ -0,0 +1,409 @@ +import asyncio +import json +from io import BytesIO +from unittest.mock import ANY, AsyncMock, patch + +from django.apps.registry import apps +from django.test import TestCase +from forestadmin.agent_toolkit.utils.context import FileResponse, Request, RequestMethod, Response +from forestadmin.django_agent.agent import DjangoAgent + + +class TestDjangoAgentRoutes(TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.loop = asyncio.new_event_loop() + cls.django_agent: DjangoAgent = apps.get_app_config("django_agent").get_agent() + + cls.mocked_resources = {} + for key in [ + "authentication", + "crud", + "crud_related", + "stats", + "actions", + "collection_charts", + "datasource_charts", + ]: + + def get_response(request, method_name=None): + ret = Response(200, '{"mock": "ok"}', headers={"content-type": "application/json"}) + if method_name == "csv": + ret = FileResponse(file=BytesIO(b"test file"), name="text.csv", mimetype="text/csv;charset=UTF-8") + return ret + + cls.mocked_resources[key] = AsyncMock() + cls.mocked_resources[key].dispatch = AsyncMock(side_effect=get_response) + + patch.object( + cls.django_agent, "get_resources", new_callable=AsyncMock, return_value=cls.mocked_resources + ).start() + + cls.conf_prefix = cls.django_agent.options.get("prefix", "") + # here the same rules as in urls.py + if len(cls.conf_prefix) > 0 and cls.conf_prefix[-1] != "/": + cls.conf_prefix = f"{cls.conf_prefix}/" + if len(cls.conf_prefix) > 0 and cls.conf_prefix[0] == "/": + cls.conf_prefix = f"{cls.conf_prefix[1:]}" + + patch("forestadmin.django_agent.apps.is_launch_as_server", return_value=True).start() + + super().setUpClass() + + +class TestDjangoAgentGenericRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + def test_index(self): + response = self.client.get(f"/{self.conf_prefix}forest/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"") + + def test_scope_cache_invalidation(self): + response = self.client.get( + f"/{self.conf_prefix}forest/scope-cache-invalidation", + HTTP_X_FORWARDED_FOR="179.114.131.49", + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(response.content, b"") + + +class TestDjangoAgentAuthenticationRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.authentication_resource = cls.loop.run_until_complete(cls.django_agent.get_resources())["authentication"] + + def test_authenticate(self): + response = self.client.post( + f"/{self.conf_prefix}forest/authentication", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + HTTP_X_FORWARDED_FOR="179.114.131.49", + ) + self.authentication_resource.dispatch.assert_any_await(ANY, "authenticate") + request_param: Request = self.authentication_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.client_ip, "179.114.131.49") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"mock": "ok"}) + self.assertEqual(response.has_header("content-type"), True) + self.assertEqual(response.headers["content-type"], "application/json") + self.authentication_resource.dispatch.reset_mock() + + def test_callback(self): + response = self.client.post( + f"/{self.conf_prefix}forest/authentication/callback", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + HTTP_X_FORWARDED_FOR="179.114.131.49", + ) + self.authentication_resource.dispatch.assert_any_await(ANY, "callback") + request_param: Request = self.authentication_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.client_ip, "179.114.131.49") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"mock": "ok"}) + self.assertEqual(response.has_header("content-type"), True) + self.assertEqual(response.headers["content-type"], "application/json") + self.authentication_resource.dispatch.reset_mock() + + +class TestDjangoAgentActionsRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.action_resource = cls.loop.run_until_complete(cls.django_agent.get_resources())["actions"] + + def test_hook_load(self): + response = self.client.post( + f"/{self.conf_prefix}forest/_actions/customer/1/action_name/hooks/load", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + HTTP_X_FORWARDED_FOR="179.114.131.49", + ) + self.action_resource.dispatch.assert_any_await(ANY, "hook") + request_param: Request = self.action_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.client_ip, "179.114.131.49") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"mock": "ok"}) + self.assertEqual(response.has_header("content-type"), True) + self.assertEqual(response.headers["content-type"], "application/json") + self.action_resource.dispatch.reset_mock() + + def test_hook_change(self): + response = self.client.post( + f"/{self.conf_prefix}forest/_actions/customer/1/action_name/hooks/change", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + HTTP_X_FORWARDED_FOR="179.114.131.49", + ) + self.action_resource.dispatch.assert_any_await(ANY, "hook") + request_param: Request = self.action_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.client_ip, "179.114.131.49") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"mock": "ok"}) + self.assertEqual(response.has_header("content-type"), True) + self.assertEqual(response.headers["Content-Type"], "application/json") + self.action_resource.dispatch.reset_mock() + + def test_execute(self): + response = self.client.post( + f"/{self.conf_prefix}forest/_actions/customer/1/action_name", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + HTTP_X_FORWARDED_FOR="179.114.131.49", + ) + self.action_resource.dispatch.assert_any_await(ANY, "execute") + request_param: Request = self.action_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.client_ip, "179.114.131.49") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"mock": "ok"}) + self.assertEqual(response.has_header("content-type"), True) + self.assertEqual(response.headers["content-type"], "application/json") + self.action_resource.dispatch.reset_mock() + + +class TestDjangoAgentCrudRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.crud_resource = cls.loop.run_until_complete(cls.django_agent.get_resources())["crud"] + + def test_list(self): + self.client.get(f"/{self.conf_prefix}forest/customer") + self.crud_resource.dispatch.assert_any_await(ANY, "list") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.GET) + self.assertEqual(request_param.query["collection_name"], "customer") + + def test_get(self): + self.client.get(f"/{self.conf_prefix}forest/customer/12") + self.crud_resource.dispatch.assert_any_await(ANY, "get") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.GET) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + + def test_count(self): + self.client.get(f"/{self.conf_prefix}forest/customer/count") + self.crud_resource.dispatch.assert_any_await(ANY, "count") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.GET) + self.assertEqual(request_param.query["collection_name"], "customer") + + def test_csv(self): + response = self.client.get(f"/{self.conf_prefix}forest/customer.csv") + self.crud_resource.dispatch.assert_any_await(ANY, "csv") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.GET) + self.assertEqual(request_param.query["collection_name"], "customer") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "text/csv;charset=UTF-8") + self.assertEqual(response.headers["Content-Disposition"], "attachment; filename=text.csv") + self.assertEqual(response.content, b"test file") + + def test_add(self): + self.client.post( + f"/{self.conf_prefix}forest/customer", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.crud_resource.dispatch.assert_any_await(ANY, "add") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + def test_update(self): + self.client.put( + f"/{self.conf_prefix}forest/customer/12", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.crud_resource.dispatch.assert_any_await(ANY, "update") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.PUT) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + def test_delete(self): + self.client.delete(f"/{self.conf_prefix}forest/customer/12") + self.crud_resource.dispatch.assert_any_await(ANY, "delete") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.DELETE) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + + def test_delete_list(self): + self.client.delete( + f"/{self.conf_prefix}forest/customer", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.crud_resource.dispatch.assert_any_await(ANY, "delete_list") + request_param: Request = self.crud_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.DELETE) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + +class TestDjangoAgentCrudRelatedRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.crud_related_resource = cls.loop.run_until_complete(cls.django_agent.get_resources())["crud_related"] + + def test_list(self): + self.client.get(f"/{self.conf_prefix}forest/customer/12/relationships/groups") + self.crud_related_resource.dispatch.assert_any_await(ANY, "list") + request_param: Request = self.crud_related_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.GET) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + self.assertEqual(request_param.query["relation_name"], "groups") + + def test_count(self): + self.client.get(f"/{self.conf_prefix}forest/customer/12/relationships/groups/count") + self.crud_related_resource.dispatch.assert_any_await(ANY, "count") + request_param: Request = self.crud_related_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.GET) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + self.assertEqual(request_param.query["relation_name"], "groups") + + def test_csv(self): + response = self.client.get(f"/{self.conf_prefix}forest/customer/12/relationships/groups.csv") + self.crud_related_resource.dispatch.assert_any_await(ANY, "csv") + request_param: Request = self.crud_related_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.GET) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + self.assertEqual(request_param.query["relation_name"], "groups") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], "text/csv;charset=UTF-8") + self.assertEqual(response.headers["Content-Disposition"], "attachment; filename=text.csv") + self.assertEqual(response.content, b"test file") + + def test_add(self): + self.client.post( + f"/{self.conf_prefix}forest/customer/12/relationships/groups", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.crud_related_resource.dispatch.assert_any_await(ANY, "add") + request_param: Request = self.crud_related_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + self.assertEqual(request_param.query["relation_name"], "groups") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + def test_update(self): + self.client.put( + f"/{self.conf_prefix}forest/customer/12/relationships/groups", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.crud_related_resource.dispatch.assert_any_await(ANY, "update_list") + request_param: Request = self.crud_related_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.PUT) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + self.assertEqual(request_param.query["relation_name"], "groups") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + def test_delete_list(self): + self.client.delete( + f"/{self.conf_prefix}forest/customer/12/relationships/groups", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.crud_related_resource.dispatch.assert_any_await(ANY, "delete_list") + request_param: Request = self.crud_related_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.DELETE) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["pks"], "12") + self.assertEqual(request_param.query["relation_name"], "groups") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + +class TestDjangoAgentCollectionChartRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.collection_chart_resource = cls.loop.run_until_complete(cls.django_agent.get_resources())[ + "collection_charts" + ] + + def test_delete_list(self): + self.client.post( + f"/{self.conf_prefix}forest/_charts/customer/first_chart", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.collection_chart_resource.dispatch.assert_any_await(ANY, "add") + request_param: Request = self.collection_chart_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.query["chart_name"], "first_chart") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + +class TestDjangoAgentDatasourceChartRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.datasource_chart_resource = cls.loop.run_until_complete(cls.django_agent.get_resources())[ + "datasource_charts" + ] + + def test_delete_list(self): + self.client.post( + f"/{self.conf_prefix}forest/_charts/first_chart", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.datasource_chart_resource.dispatch.assert_any_await(ANY, "add") + request_param: Request = self.datasource_chart_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.query["chart_name"], "first_chart") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) + + +class TestDjangoAgentStatRoutes(TestDjangoAgentRoutes): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.stats_resource = cls.loop.run_until_complete(cls.django_agent.get_resources())["stats"] + + def test_delete_list(self): + self.client.post( + f"/{self.conf_prefix}forest/stats/customer", + json.dumps({"post_attr": "post_value"}), + content_type="application/json", + ) + self.stats_resource.dispatch.assert_any_await(ANY) + request_param: Request = self.stats_resource.dispatch.await_args[0][0] + self.assertEqual(request_param.method, RequestMethod.POST) + self.assertEqual(request_param.query["collection_name"], "customer") + self.assertEqual(request_param.body, {"post_attr": "post_value"}) diff --git a/src/django_agent/tests/test_project_agent/.forestadmin-schema.json b/src/django_agent/tests/test_project_agent/.forestadmin-schema.json new file mode 100644 index 000000000..4eb86ff3b --- /dev/null +++ b/src/django_agent/tests/test_project_agent/.forestadmin-schema.json @@ -0,0 +1,1335 @@ +{ + "collections": [ + { + "name": "Book", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "author", + "inverseOf": "books", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Person.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 254, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "rating", + "inverseOf": "book", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Rating.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + } + ] + }, + { + "name": "ContentType", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "app_label", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 100, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "model", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 100, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "permission", + "inverseOf": "content_type", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Permission.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + } + ] + }, + { + "name": "Group", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "Group_permissions+", + "inverseOf": "group", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Group_permissions.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "User_groups+", + "inverseOf": "group", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "User_groups.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 150, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "permissions", + "inverseOf": "group", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Permission.id", + "relationship": "BelongsToMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "user", + "inverseOf": "groups", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "User.id", + "relationship": "BelongsToMany", + "type": [ + "Number" + ], + "validations": [] + } + ] + }, + { + "name": "Group_permissions", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "group", + "inverseOf": "Group_permissions+", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Group.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "permission", + "inverseOf": "Group_permissions+", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Permission.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + } + ] + }, + { + "name": "Permission", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "Group_permissions+", + "inverseOf": "permission", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Group_permissions.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "User_user_permissions+", + "inverseOf": "permission", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "User_user_permissions.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "codename", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 100, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "content_type", + "inverseOf": "permission", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "ContentType.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "group", + "inverseOf": "permissions", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Group.id", + "relationship": "BelongsToMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 255, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "user", + "inverseOf": "user_permissions", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "User.id", + "relationship": "BelongsToMany", + "type": [ + "Number" + ], + "validations": [] + } + ] + }, + { + "name": "Person", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "birth_date", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Dateonly", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "books", + "inverseOf": "author", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Book.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "first_name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 254, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "last_name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 254, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "rating", + "inverseOf": "commenter", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Rating.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + } + ] + }, + { + "name": "Rating", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "book", + "inverseOf": "rating", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Book.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "comment", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "commenter", + "inverseOf": "rating", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Person.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "rated_at", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Dateonly", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": [ + 1, + 2, + 3, + 4, + 5 + ], + "field": "rating", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Enum", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + } + ] + }, + { + "name": "User", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "User_groups+", + "inverseOf": "user", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "User_groups.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "User_user_permissions+", + "inverseOf": "user", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "User_user_permissions.id", + "relationship": "HasMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "date_joined", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "email", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is shorter than", + "value": 254, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "first_name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is shorter than", + "value": 150, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "groups", + "inverseOf": "user", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Group.id", + "relationship": "BelongsToMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": true, + "enums": null, + "field": "is_active", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [] + }, + { + "defaultValue": false, + "enums": null, + "field": "is_staff", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [] + }, + { + "defaultValue": false, + "enums": null, + "field": "is_superuser", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "last_login", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "last_name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is shorter than", + "value": 150, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "password", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 128, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "user_permissions", + "inverseOf": "user", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Permission.id", + "relationship": "BelongsToMany", + "type": [ + "Number" + ], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "username", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + }, + { + "type": "is shorter than", + "value": 150, + "message": null + } + ] + } + ] + }, + { + "name": "User_groups", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "group", + "inverseOf": "User_groups+", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Group.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "user", + "inverseOf": "User_groups+", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "User.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + } + ] + }, + { + "name": "User_user_permissions", + "isVirtual": false, + "icon": null, + "isReadOnly": false, + "integration": null, + "isSearchable": true, + "onlyForRelationships": false, + "paginationType": "page", + "searchField": null, + "actions": [], + "segments": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "permission", + "inverseOf": "User_user_permissions+", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Permission.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "user", + "inverseOf": "User_user_permissions+", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "User.id", + "relationship": "BelongsTo", + "type": "Number", + "validations": [ + { + "type": "is present", + "value": null, + "message": null + } + ] + } + ] + } + ], + "meta": { + "liana": "agent-python", + "liana_version": "1.1.0", + "stack": { + "engine": "python", + "engine_version": "3.10.11" + }, + "schemaFileHash": "bd40c659f8e01ef0aa63ae48c48d5d02250cde66" + } +} \ No newline at end of file diff --git a/src/django_agent/tests/test_project_agent/__init__.py b/src/django_agent/tests/test_project_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/django_agent/tests/test_project_agent/manage.py b/src/django_agent/tests/test_project_agent/manage.py new file mode 100755 index 000000000..0dec50ee5 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project_agent.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/src/django_agent/tests/test_project_agent/test_app/__init__.py b/src/django_agent/tests/test_project_agent/test_app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/django_agent/tests/test_project_agent/test_app/app.py b/src/django_agent/tests/test_project_agent/test_app/app.py new file mode 100644 index 000000000..953db2940 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_app/app.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TestAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "test_app" diff --git a/src/django_agent/tests/test_project_agent/test_app/fixtures/__init__.py b/src/django_agent/tests/test_project_agent/test_app/fixtures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/django_agent/tests/test_project_agent/test_app/fixtures/book.json b/src/django_agent/tests/test_project_agent/test_app/fixtures/book.json new file mode 100644 index 000000000..86639a3d9 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_app/fixtures/book.json @@ -0,0 +1,12 @@ +[ + { + "model": "test_app.book", + "pk": 1, + "fields": { "name": "Foundation", "author": 1 } + }, + { + "model": "test_app.book", + "pk": 2, + "fields": { "name": "Harry Potter", "author": 2 } + } +] diff --git a/src/django_agent/tests/test_project_agent/test_app/fixtures/person.json b/src/django_agent/tests/test_project_agent/test_app/fixtures/person.json new file mode 100644 index 000000000..e0bcee24f --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_app/fixtures/person.json @@ -0,0 +1,20 @@ +[ + { + "model": "test_app.person", + "pk": 1, + "fields": { + "first_name": "Isaac", + "last_name": "Asimov", + "birth_date": "1920-02-01" + } + }, + { + "model": "test_app.person", + "pk": 2, + "fields": { + "first_name": "J.K.", + "last_name": "Rowling", + "birth_date": "1965-07-31" + } + } +] diff --git a/src/django_agent/tests/test_project_agent/test_app/fixtures/rating.json b/src/django_agent/tests/test_project_agent/test_app/fixtures/rating.json new file mode 100644 index 000000000..dc0fbd350 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_app/fixtures/rating.json @@ -0,0 +1,57 @@ +[ + { + "model": "test_app.rating", + "pk": 1, + "fields": { + "comment": "", + "commenter": 1, + "book": 1, + "rating": 1, + "rated_at": "2022-12-25" + } + }, + { + "model": "test_app.rating", + "pk": 2, + "fields": { + "comment": null, + "commenter": 1, + "book": 1, + "rating": 1, + "rated_at": "2023-01-12" + } + }, + { + "model": "test_app.rating", + "pk": 3, + "fields": { + "comment": "Best book ever", + "commenter": 1, + "book": 1, + "rating": 5, + "rated_at": "2023-02-02" + } + }, + { + "model": "test_app.rating", + "pk": 4, + "fields": { + "comment": "incredible", + "commenter": 1, + "book": 1, + "rating": 5, + "rated_at": "2023-02-25" + } + }, + { + "model": "test_app.rating", + "pk": 5, + "fields": { + "comment": "awesome", + "commenter": 2, + "book": 2, + "rating": 5, + "rated_at": "2023-03-02" + } + } +] diff --git a/src/django_agent/tests/test_project_agent/test_app/forest_admin.py b/src/django_agent/tests/test_project_agent/test_app/forest_admin.py new file mode 100644 index 000000000..c0fd2dae7 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_app/forest_admin.py @@ -0,0 +1,5 @@ +from forestadmin.django_agent.agent import DjangoAgent + + +def customize_agent(agent: DjangoAgent): + pass diff --git a/src/django_agent/tests/test_project_agent/test_app/migrations/0001_initial.py b/src/django_agent/tests/test_project_agent/test_app/migrations/0001_initial.py new file mode 100644 index 000000000..13d3ffce2 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_app/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2 on 2023-11-16 17:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Book", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=254)), + ], + ), + migrations.CreateModel( + name="Person", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("first_name", models.CharField(max_length=254)), + ("last_name", models.CharField(max_length=254)), + ("birth_date", models.DateField()), + ], + ), + migrations.CreateModel( + name="Rating", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("comment", models.TextField(null=True)), + ("rating", models.IntegerField(choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)])), + ("rated_at", models.DateField()), + ("book", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="test_app.book")), + ("commenter", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="test_app.person")), + ], + ), + migrations.AddField( + model_name="book", + name="author", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="books", to="test_app.person" + ), + ), + ] diff --git a/src/django_agent/tests/test_project_agent/test_app/migrations/__init__.py b/src/django_agent/tests/test_project_agent/test_app/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/django_agent/tests/test_project_agent/test_app/models.py b/src/django_agent/tests/test_project_agent/test_app/models.py new file mode 100644 index 000000000..5a0703422 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_app/models.py @@ -0,0 +1,27 @@ +from django.db import models + + +class Book(models.Model): + name = models.CharField(max_length=254) + author = models.ForeignKey("Person", on_delete=models.CASCADE, related_name="books") + + +class Person(models.Model): + first_name = models.CharField(max_length=254) + last_name = models.CharField(max_length=254) + birth_date = models.DateField() + + +class Rating(models.Model): + RATE_CHOICES = [ + (1, 1), + (2, 2), + (3, 3), + (4, 4), + (5, 5), + ] + comment = models.TextField(null=True) + commenter = models.ForeignKey(Person, on_delete=models.CASCADE) + book = models.ForeignKey(Book, on_delete=models.CASCADE) + rating = models.IntegerField(choices=RATE_CHOICES) + rated_at = models.DateField() diff --git a/src/django_agent/tests/test_project_agent/test_project_agent/settings.py b/src/django_agent/tests/test_project_agent/test_project_agent/settings.py new file mode 100644 index 000000000..1996f4228 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_project_agent/settings.py @@ -0,0 +1,32 @@ +import os + +DEBUG = True +SECRET_KEY = "the_secret_key" +INSTALLED_APPS = [ + "forestadmin.django_agent", + "test_app", + "django.contrib.auth", + "django.contrib.contenttypes", +] +ROOT_URLCONF = "test_project_agent.urls" + +FOREST_ENV_SECRET = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +FOREST_AUTH_SECRET = "OfpssLrbgF3P4vHJTTpb" +FOREST_PREFIX = "/my_forest" +# FOREST_CUSTOMIZE_FUNCTION = "test_app.forest_admin.customize_agent" + + +DB_PATH = os.path.abspath(os.path.join(__file__, "..", "..", "test_db.sqlite")) +USE_TZ = True +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": DB_PATH, + # "TEST_NAME": DB_PATH, + # "OPTIONS": { + # "timeout": 2000, + # # "init_command": "SET storage_engine=MEMORY", + # }, + "TEST": {"NAME": DB_PATH}, + } +} diff --git a/src/django_agent/tests/test_project_agent/test_project_agent/urls.py b/src/django_agent/tests/test_project_agent/test_project_agent/urls.py new file mode 100644 index 000000000..9ffbf2aa2 --- /dev/null +++ b/src/django_agent/tests/test_project_agent/test_project_agent/urls.py @@ -0,0 +1,5 @@ +from django.urls import include, path + +urlpatterns = [ + path("", include("forestadmin.django_agent.urls")), +] From 19c904e5270271a40e7d131815e6c00fcca9376b Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 15:06:34 +0100 Subject: [PATCH 11/19] chore(ci): enable ci for django_agent package --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44fae9574..4542c574b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,7 @@ jobs: GenericPackage: uses: ./.github/workflows/generic.yml with: - packages: '["./src/datasource_toolkit/", "./src/datasource_django/", "./src/datasource_sqlalchemy/", "./src/agent_toolkit/", "./src/flask_agent/"]' - # packages: '["./src/datasource_toolkit/", "./src/datasource_django/", "./src/datasource_sqlalchemy/", "./src/agent_toolkit/", "./src/flask_agent/", "./src/django_agent/"]' + packages: '["./src/datasource_toolkit/", "./src/datasource_django/", "./src/datasource_sqlalchemy/", "./src/agent_toolkit/", "./src/flask_agent/", "./src/django_agent/"]' secrets: CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} From dd8d153fb354184f47b062bf774387d55745bf73 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 16:29:21 +0100 Subject: [PATCH 12/19] chore(django_agent): change setting name --- src/_example/django/django_demo/django_demo/settings.py | 2 +- src/django_agent/forestadmin/django_agent/apps.py | 2 +- src/django_agent/tests/test_agent_creation.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_example/django/django_demo/django_demo/settings.py b/src/_example/django/django_demo/django_demo/settings.py index efadfb0a0..20dc793cb 100644 --- a/src/_example/django/django_demo/django_demo/settings.py +++ b/src/_example/django/django_demo/django_demo/settings.py @@ -39,7 +39,7 @@ FOREST_IS_PRODUCTION = str2bool(os.environ.get("FOREST_IS_PRODUCTION", "False")) # if you want to manually add datasource with option you can set this var to True and # add a datasource in the 'FOREST_CUSTOMIZE_FUNCTION' -# FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE = False +# FOREST_AUTO_ADD_DJANGO_DATASOURCE = True FOREST_CUSTOMIZE_FUNCTION = "app.forest_admin.customize_forest" # from app.forest_admin import customize_forest diff --git a/src/django_agent/forestadmin/django_agent/apps.py b/src/django_agent/forestadmin/django_agent/apps.py index f51561548..3e88c73dd 100644 --- a/src/django_agent/forestadmin/django_agent/apps.py +++ b/src/django_agent/forestadmin/django_agent/apps.py @@ -18,7 +18,7 @@ def is_launch_as_server() -> bool: def init_app_agent() -> Optional[DjangoAgent]: agent = create_agent() - if not getattr(settings, "FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE", None): + if not hasattr(settings, "FOREST_AUTO_ADD_DJANGO_DATASOURCE") or settings.FOREST_AUTO_ADD_DJANGO_DATASOURCE: agent.add_datasource(DjangoDatasource()) customize_fn = getattr(settings, "FOREST_CUSTOMIZE_FUNCTION", None) diff --git a/src/django_agent/tests/test_agent_creation.py b/src/django_agent/tests/test_agent_creation.py index 531d61a93..d7bc4a94d 100644 --- a/src/django_agent/tests/test_agent_creation.py +++ b/src/django_agent/tests/test_agent_creation.py @@ -122,7 +122,7 @@ def test_should_add_datasource_when_no_setting(self): mock_django_datasource.assert_called_once() def test_should_add_datasource_not_called_when_no_auto_added_asked(self): - with override_settings(**self.dj_options, FOREST_DONT_AUTO_ADD_DJANGO_DATASOURCE=True): + with override_settings(**self.dj_options, FOREST_AUTO_ADD_DJANGO_DATASOURCE=False): with patch("forestadmin.django_agent.apps.DjangoDatasource") as mock_django_datasource: with patch.object(DjangoAgent, "add_datasource") as mock_add_datasource: init_app_agent() From 42f760b0cf57f87d16122b9a351bbb92aad634f7 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 16:34:37 +0100 Subject: [PATCH 13/19] chore(django): try to fix version dependencies issues --- src/datasource_django/pyproject.toml | 14 +++++++------- src/django_agent/pyproject.toml | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/datasource_django/pyproject.toml b/src/datasource_django/pyproject.toml index 1b23094e1..3f165f067 100644 --- a/src/datasource_django/pyproject.toml +++ b/src/datasource_django/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "forestadmin-datasource-django" -version = "1.1.0" +version = "1.2.0-beta.1" description = "" authors = [ "Julien Barreau ",] readme = "README.md" @@ -17,8 +17,12 @@ typing-extensions = "~=4.2" tzdata = "~=2022.6" django = ">= 3.2" psycopg2 = ">=2.8.4" -forestadmin-datasource-toolkit = "1.1.0" -forestadmin-agent-toolkit = "1.1.0" +forestadmin-datasource-toolkit = "1.2.0-beta.1" +forestadmin-agent-toolkit = "1.2.0-beta.1" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "test_project_datasource.settings" +pythonpath = "tests/test_project_datasource" [tool.poetry.dependencies."backports.zoneinfo"] version = "~=0.2.1" @@ -67,7 +71,3 @@ develop = true [tool.poetry.group.test.dependencies.forestadmin-agent-toolkit] path = "../agent_toolkit" develop = true - -[tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "test_project_datasource.settings" -pythonpath = "tests/test_project_datasource" diff --git a/src/django_agent/pyproject.toml b/src/django_agent/pyproject.toml index c1eb972d6..009c67bdd 100644 --- a/src/django_agent/pyproject.toml +++ b/src/django_agent/pyproject.toml @@ -4,9 +4,9 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "forestadmin-agent-django" -version = "1.1.0" +version = "1.2.0-beta.1" description = "" -authors = ["Julien Barreau ",] +authors = [ "Julien Barreau ",] readme = "README.md" [[tool.poetry.packages]] include = "forestadmin" @@ -15,10 +15,14 @@ include = "forestadmin" python = ">=3.8,<4.0" typing-extensions = "~=4.2" tzdata = "~=2022.6" -forestadmin-agent-toolkit = "1.1.0" -forestadmin-datasource-django = "1.1.0" +forestadmin-agent-toolkit = "1.2.0-beta.1" +forestadmin-datasource-django = "1.2.0-beta.1" django = ">=3.2" +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "test_project_agent.settings" +pythonpath = "tests/test_project_agent" + [tool.poetry.dependencies."backports.zoneinfo"] version = "~=0.2.1" python = "<3.9" @@ -65,7 +69,3 @@ develop = true [tool.poetry.group.test.dependencies.forestadmin-agent-toolkit] path = "../agent_toolkit" develop = true - -[tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "test_project_agent.settings" -pythonpath = "tests/test_project_agent" From 71341dfb6a051fa9764db1d5b927d83952894350 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 17:16:03 +0100 Subject: [PATCH 14/19] chore(django_agent): fix test setup --- src/django_agent/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/django_agent/pyproject.toml b/src/django_agent/pyproject.toml index 009c67bdd..216087fa6 100644 --- a/src/django_agent/pyproject.toml +++ b/src/django_agent/pyproject.toml @@ -46,6 +46,7 @@ pytest-asyncio = "~=0.18" coverage = "~=6.5" freezegun = "~=1.2.0" pytest-cov = "^4.0.0" +pytest-django = ">=4.0.0" [tool.poetry.group.linter.dependencies] [[tool.poetry.group.linter.dependencies.flake8]] From f152159d23373cb68206150604290f9db33ad06a Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 17:33:29 +0100 Subject: [PATCH 15/19] chore: get rid of duplicated code --- .../datasource_django/collection.py | 20 ----- .../datasource_django/utils/type_converter.py | 76 +----------------- .../datasource_sqlalchemy/collections.py | 18 ----- .../utils/type_converter.py | 77 +------------------ .../datasource_toolkit/collections.py | 33 +++++++- .../interfaces/collections.py | 4 - .../datasource_toolkit/utils/operators.py | 76 ++++++++++++++++++ 7 files changed, 115 insertions(+), 189 deletions(-) create mode 100644 src/datasource_toolkit/forestadmin/datasource_toolkit/utils/operators.py diff --git a/src/datasource_django/forestadmin/datasource_django/collection.py b/src/datasource_django/forestadmin/datasource_django/collection.py index 819c32691..7790d5300 100644 --- a/src/datasource_django/forestadmin/datasource_django/collection.py +++ b/src/datasource_django/forestadmin/datasource_django/collection.py @@ -7,8 +7,6 @@ from forestadmin.datasource_django.utils.query_factory import DjangoQueryBuilder from forestadmin.datasource_django.utils.record_serializer import instance_to_record_data from forestadmin.datasource_toolkit.datasources import Datasource -from forestadmin.datasource_toolkit.interfaces.actions import ActionField, ActionResult -from forestadmin.datasource_toolkit.interfaces.chart import Chart from forestadmin.datasource_toolkit.interfaces.fields import is_column from forestadmin.datasource_toolkit.interfaces.query.aggregation import AggregateResult, Aggregation from forestadmin.datasource_toolkit.interfaces.query.filter.paginated import PaginatedFilter @@ -50,24 +48,6 @@ async def update(self, caller: User, filter_: Optional[Filter], patch: RecordsDa async def delete(self, caller: User, filter_: Optional[Filter]) -> None: await DjangoQueryBuilder.mk_delete(self, filter_) - async def execute( - self, caller: User, name: str, data: RecordsDataAlias, filter_: Optional[Filter] - ) -> ActionResult: # TODO: duplicate - return await super().execute(caller, name, data, filter_) - - async def get_form( - self, - caller: User, - name: str, - data: Optional[RecordsDataAlias], - filter_: Optional[Filter], - meta: Optional[Dict[str, Any]], - ) -> List[ActionField]: # TODO: duplicate - return await super().get_form(caller, name, data, filter_, meta) - - async def render_chart(self, caller: User, name: str, record_id: List) -> Chart: # duplicate - return await super().render_chart(caller, name, record_id) - def get_native_driver(self): # TODO return super().get_native_driver() diff --git a/src/datasource_django/forestadmin/datasource_django/utils/type_converter.py b/src/datasource_django/forestadmin/datasource_django/utils/type_converter.py index e8e89b6c3..a91dd0e6c 100644 --- a/src/datasource_django/forestadmin/datasource_django/utils/type_converter.py +++ b/src/datasource_django/forestadmin/datasource_django/utils/type_converter.py @@ -1,9 +1,10 @@ -from typing import Dict, Set, Tuple +from typing import Dict, Tuple from django.contrib.postgres import fields as postgres_fields from django.db import models from forestadmin.datasource_django.exception import DjangoDatasourceException from forestadmin.datasource_toolkit.interfaces.fields import ColumnAlias, Operator, PrimitiveType +from forestadmin.datasource_toolkit.utils.operators import BaseFilterOperator class ConverterException(DjangoDatasourceException): @@ -77,15 +78,7 @@ def convert(cls, field: models.Field) -> ColumnAlias: raise ConverterException(f'Type "{field.__class__}" is unknown') -class FilterOperator: - COMMON_OPERATORS: Set[Operator] = { # TODO: duplicated - Operator.BLANK, - Operator.EQUAL, - Operator.MISSING, - Operator.NOT_EQUAL, - Operator.PRESENT, - } - +class FilterOperator(BaseFilterOperator): OPERATORS = { # operator: (lookup_expr, negate needed) Operator.EQUAL: ("", False), @@ -114,66 +107,3 @@ def get_operator(cls, operator: Operator) -> Tuple[str, bool]: return cls.OPERATORS[operator] except KeyError: raise ConverterException(f"Unable to handle the operator {operator}") - - @classmethod - def get_for_type(cls, _type: ColumnAlias) -> Set[Operator]: # TODO: duplicated - operators: Set[Operator] = set() - if isinstance(_type, list): - operators = { - *cls.COMMON_OPERATORS, - Operator.IN, - Operator.INCLUDES_ALL, - Operator.NOT_IN, - } - elif _type == PrimitiveType.BOOLEAN: - operators = cls.COMMON_OPERATORS - elif _type == PrimitiveType.UUID: - operators = { - *cls.COMMON_OPERATORS, - Operator.CONTAINS, - Operator.ENDS_WITH, - Operator.LIKE, - Operator.STARTS_WITH, - } - elif _type == PrimitiveType.NUMBER: - operators = { - *cls.COMMON_OPERATORS, - Operator.GREATER_THAN, - Operator.LESS_THAN, - Operator.IN, - Operator.NOT_IN, - } - elif _type == PrimitiveType.STRING: - operators = { - *cls.COMMON_OPERATORS, - Operator.CONTAINS, - Operator.ENDS_WITH, - Operator.IN, - Operator.LIKE, - Operator.LONGER_THAN, - Operator.NOT_CONTAINS, - Operator.NOT_IN, - Operator.SHORTER_THAN, - Operator.STARTS_WITH, - } - elif _type in [ - PrimitiveType.DATE, - PrimitiveType.DATE_ONLY, - PrimitiveType.TIME_ONLY, - ]: - operators = { - *cls.COMMON_OPERATORS, - Operator.GREATER_THAN, - Operator.LESS_THAN, - } - elif _type == PrimitiveType.ENUM: - operators = {*cls.COMMON_OPERATORS, Operator.IN, Operator.NOT_IN} - elif _type == PrimitiveType.JSON: - operators = cls.COMMON_OPERATORS - elif _type == PrimitiveType.BINARY: - operators = { - *cls.COMMON_OPERATORS, - Operator.IN, - } - - return operators diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/collections.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/collections.py index b5616a5f7..61847bfbd 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/collections.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/collections.py @@ -23,8 +23,6 @@ projections_to_records, ) from forestadmin.datasource_sqlalchemy.utils.relationships import Relationships, merge_relationships -from forestadmin.datasource_toolkit.interfaces.actions import ActionField, ActionResult -from forestadmin.datasource_toolkit.interfaces.chart import Chart from forestadmin.datasource_toolkit.interfaces.fields import PrimitiveType, RelationAlias from forestadmin.datasource_toolkit.interfaces.query.aggregation import AggregateResult, Aggregation from forestadmin.datasource_toolkit.interfaces.query.condition_tree.nodes.base import ConditionTree @@ -228,19 +226,3 @@ async def delete(self, caller: User, filter_: Optional[Filter]) -> None: with self.datasource.Session.begin() as session: # type: ignore query = QueryFactory.delete(self, filter_) session.execute(query) # type: ignore - - async def get_form( - self, - caller: User, - name: str, - data: Optional[RecordsDataAlias], - filter_: Optional[Filter], - meta: Optional[Dict[str, Any]], - ) -> List[ActionField]: - return await super().get_form(caller, name, data, filter_, meta) - - async def execute(self, caller: User, name: str, data: RecordsDataAlias, filter_: Optional[Filter]) -> ActionResult: - return await super().execute(caller, name, data, filter_) - - async def render_chart(self, caller: User, name: str, record_id: List) -> Chart: - return await super().render_chart(caller, name, record_id) diff --git a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/type_converter.py b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/type_converter.py index 70f5b29ba..fb6cf9970 100644 --- a/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/type_converter.py +++ b/src/datasource_sqlalchemy/forestadmin/datasource_sqlalchemy/utils/type_converter.py @@ -1,7 +1,8 @@ -from typing import Any, Callable, Dict, Optional, Set, Type +from typing import Any, Callable, Dict, Optional, Type from forestadmin.datasource_toolkit.exceptions import DatasourceToolkitException from forestadmin.datasource_toolkit.interfaces.fields import ColumnAlias, Operator, PrimitiveType +from forestadmin.datasource_toolkit.utils.operators import BaseFilterOperator from sqlalchemy import ARRAY # type: ignore from sqlalchemy import column as SqlAlchemyColumn # type: ignore from sqlalchemy import func, not_, or_ # type: ignore @@ -55,15 +56,7 @@ def convert(cls, _type: sqltypes.TypeEngine) -> ColumnAlias: raise ConverterException(f'Type "{_type.__class__}" is unknown') -class FilterOperator: - COMMON_OPERATORS: Set[Operator] = { - Operator.BLANK, - Operator.EQUAL, - Operator.MISSING, - Operator.NOT_EQUAL, - Operator.PRESENT, - } - +class FilterOperator(BaseFilterOperator): OPERATORS = { Operator.EQUAL: "_equal_operator", Operator.NOT_EQUAL: "_not_equal_operator", @@ -153,6 +146,7 @@ def wrapped(_: str): @staticmethod def _includes_all(column: SqlAlchemyColumn): + # TODO: this is false, include is more what we want return column.__eq__ @staticmethod @@ -167,66 +161,3 @@ def get_operator(cls, columns: SqlAlchemyColumn, operator: Operator) -> Callable raise ConverterException(f"Unable to handle the operator {operator}") else: return getattr(cls, meth)(columns[0]) - - @classmethod - def get_for_type(cls, _type: ColumnAlias) -> Set[Operator]: - operators: Set[Operator] = set() - if isinstance(_type, list): - operators = { - *cls.COMMON_OPERATORS, - Operator.IN, - Operator.INCLUDES_ALL, - Operator.NOT_IN, - } - elif _type == PrimitiveType.BOOLEAN: - operators = cls.COMMON_OPERATORS - elif _type == PrimitiveType.UUID: - operators = { - *cls.COMMON_OPERATORS, - Operator.CONTAINS, - Operator.ENDS_WITH, - Operator.LIKE, - Operator.STARTS_WITH, - } - elif _type == PrimitiveType.NUMBER: - operators = { - *cls.COMMON_OPERATORS, - Operator.GREATER_THAN, - Operator.LESS_THAN, - Operator.IN, - Operator.NOT_IN, - } - elif _type == PrimitiveType.STRING: - operators = { - *cls.COMMON_OPERATORS, - Operator.CONTAINS, - Operator.ENDS_WITH, - Operator.IN, - Operator.LIKE, - Operator.LONGER_THAN, - Operator.NOT_CONTAINS, - Operator.NOT_IN, - Operator.SHORTER_THAN, - Operator.STARTS_WITH, - } - elif _type in [ - PrimitiveType.DATE, - PrimitiveType.DATE_ONLY, - PrimitiveType.TIME_ONLY, - ]: - operators = { - *cls.COMMON_OPERATORS, - Operator.GREATER_THAN, - Operator.LESS_THAN, - } - elif _type == PrimitiveType.ENUM: - operators = {*cls.COMMON_OPERATORS, Operator.IN, Operator.NOT_IN} - elif _type == PrimitiveType.JSON: - operators = cls.COMMON_OPERATORS - elif _type == PrimitiveType.BINARY: - operators = { - *cls.COMMON_OPERATORS, - Operator.IN, - } - - return operators diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/collections.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/collections.py index 75d4da36b..783f4fe13 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/collections.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/collections.py @@ -1,9 +1,15 @@ -from typing import Dict, List +from typing import Any, Dict, List, Optional +from forestadmin.agent_toolkit.utils.context import User from forestadmin.datasource_toolkit.datasources import Datasource, DatasourceException +from forestadmin.datasource_toolkit.exceptions import ForestException +from forestadmin.datasource_toolkit.interfaces.actions import ActionField, ActionResult +from forestadmin.datasource_toolkit.interfaces.chart import Chart from forestadmin.datasource_toolkit.interfaces.collections import Collection as CollectionInterface from forestadmin.datasource_toolkit.interfaces.fields import FieldAlias from forestadmin.datasource_toolkit.interfaces.models.collections import CollectionSchema +from forestadmin.datasource_toolkit.interfaces.query.filter.unpaginated import Filter +from forestadmin.datasource_toolkit.interfaces.records import RecordsDataAlias from typing_extensions import Self # from forestadmin.datasource_toolkit.decorators.action.types.actions import ActionDict @@ -67,3 +73,28 @@ def add_segments(self, segments: List[str]): def enable_search(self): self.schema["searchable"] = False + + async def execute( + self, + caller: User, + name: str, + data: RecordsDataAlias, + filter_: Optional[Filter], + ) -> ActionResult: + """to execute an action""" + raise ForestException(f"Action {name} is not implemented") + + async def get_form( + self, + caller: User, + name: str, + data: Optional[RecordsDataAlias], + filter_: Optional[Filter], + meta: Optional[Dict[str, Any]], + ) -> List[ActionField]: + """to get the form of an action""" + return [] + + async def render_chart(self, caller: User, name: str, record_id: List) -> Chart: + """to render a chart""" + raise ForestException(f"Chart {name} is not implemented") diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/collections.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/collections.py index 46c8115c9..6d742dc00 100644 --- a/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/collections.py +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/interfaces/collections.py @@ -2,7 +2,6 @@ from typing import Any, Dict, List, Optional from forestadmin.agent_toolkit.utils.context import User -from forestadmin.datasource_toolkit.exceptions import ForestException from forestadmin.datasource_toolkit.interfaces.actions import ActionField, ActionResult from forestadmin.datasource_toolkit.interfaces.chart import Chart from forestadmin.datasource_toolkit.interfaces.models.collections import Collection as CollectionModel @@ -27,7 +26,6 @@ async def execute( filter_: Optional[Filter], ) -> ActionResult: """to execute an action""" - raise ForestException(f"Action {name} is not implemented") @abc.abstractmethod async def get_form( @@ -39,12 +37,10 @@ async def get_form( meta: Optional[Dict[str, Any]], ) -> List[ActionField]: """to get the form of an action""" - return [] @abc.abstractmethod async def render_chart(self, caller: User, name: str, record_id: List) -> Chart: """to render a chart""" - raise ForestException(f"Chart {name} is not implemented") @abc.abstractmethod async def create(self, caller: User, data: List[RecordsDataAlias]) -> List[RecordsDataAlias]: diff --git a/src/datasource_toolkit/forestadmin/datasource_toolkit/utils/operators.py b/src/datasource_toolkit/forestadmin/datasource_toolkit/utils/operators.py new file mode 100644 index 000000000..749fc5df5 --- /dev/null +++ b/src/datasource_toolkit/forestadmin/datasource_toolkit/utils/operators.py @@ -0,0 +1,76 @@ +from typing import Set + +from forestadmin.datasource_toolkit.interfaces.fields import ColumnAlias, Operator, PrimitiveType + + +class BaseFilterOperator: + COMMON_OPERATORS: Set[Operator] = { + Operator.BLANK, + Operator.EQUAL, + Operator.MISSING, + Operator.NOT_EQUAL, + Operator.PRESENT, + } + + @classmethod + def get_for_type(cls, _type: ColumnAlias) -> Set[Operator]: + operators: Set[Operator] = set() + if isinstance(_type, list): + operators = { + *cls.COMMON_OPERATORS, + Operator.IN, + Operator.INCLUDES_ALL, + Operator.NOT_IN, + } + elif _type == PrimitiveType.BOOLEAN: + operators = cls.COMMON_OPERATORS + elif _type == PrimitiveType.UUID: + operators = { + *cls.COMMON_OPERATORS, + Operator.CONTAINS, + Operator.ENDS_WITH, + Operator.LIKE, + Operator.STARTS_WITH, + } + elif _type == PrimitiveType.NUMBER: + operators = { + *cls.COMMON_OPERATORS, + Operator.GREATER_THAN, + Operator.LESS_THAN, + Operator.IN, + Operator.NOT_IN, + } + elif _type == PrimitiveType.STRING: + operators = { + *cls.COMMON_OPERATORS, + Operator.CONTAINS, + Operator.ENDS_WITH, + Operator.IN, + Operator.LIKE, + Operator.LONGER_THAN, + Operator.NOT_CONTAINS, + Operator.NOT_IN, + Operator.SHORTER_THAN, + Operator.STARTS_WITH, + } + elif _type in [ + PrimitiveType.DATE, + PrimitiveType.DATE_ONLY, + PrimitiveType.TIME_ONLY, + ]: + operators = { + *cls.COMMON_OPERATORS, + Operator.GREATER_THAN, + Operator.LESS_THAN, + } + elif _type == PrimitiveType.ENUM: + operators = {*cls.COMMON_OPERATORS, Operator.IN, Operator.NOT_IN} + elif _type == PrimitiveType.JSON: + operators = cls.COMMON_OPERATORS + elif _type == PrimitiveType.BINARY: + operators = { + *cls.COMMON_OPERATORS, + Operator.IN, + } + + return operators From d9b029803261f67c20edcd81991201c1aedffbf2 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 17:53:55 +0100 Subject: [PATCH 16/19] chore(django_agent): fix test dependencies --- src/django_agent/pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/django_agent/pyproject.toml b/src/django_agent/pyproject.toml index 216087fa6..ebb193976 100644 --- a/src/django_agent/pyproject.toml +++ b/src/django_agent/pyproject.toml @@ -67,6 +67,10 @@ isort = "~=3.6" path = "../datasource_django" develop = true +[tool.poetry.group.test.dependencies.forestadmin-datasource-toolkit] +path = "../datasource_toolkit" +develop = true + [tool.poetry.group.test.dependencies.forestadmin-agent-toolkit] path = "../agent_toolkit" develop = true From cf9f7b18ab578c4cffce85571286182144950734 Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 17:54:16 +0100 Subject: [PATCH 17/19] chore(django): add details about django integration --- .gitignore | 4 +++- launch_tests_ci_like.sh | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index cd865eec2..acbceeb72 100644 --- a/.gitignore +++ b/.gitignore @@ -173,4 +173,6 @@ cython_debug/ src/agent_toolkit/poetry.lock src/datasource_sqlalchemy/poetry.lock src/datasource_toolkit/poetry.lock -src/flask_agent/poetry.lock \ No newline at end of file +src/flask_agent/poetry.lock +src/django_agent/poetry.lock +src/datasource_django/poetry.lock \ No newline at end of file diff --git a/launch_tests_ci_like.sh b/launch_tests_ci_like.sh index de4a6d9df..06d25a3a4 100755 --- a/launch_tests_ci_like.sh +++ b/launch_tests_ci_like.sh @@ -2,7 +2,7 @@ # set -x ARTIFACT_DIR="artifacts_coverages" -PACKAGES=("agent_toolkit datasource_sqlalchemy" "datasource_toolkit" "flask_agent") +PACKAGES=("agent_toolkit datasource_sqlalchemy" "datasource_toolkit" "flask_agent" "datasource_django" "django_agent") # PACKAGES=("datasource_sqlalchemy") PYTHON_VERSIONS=("3.8" "3.9" "3.10" "3.11") # PYTHON_VERSIONS=("3.8" "3.11") @@ -34,18 +34,18 @@ for sub_version in {0..21}; do SQLALCHEMY_VERSIONS+=($version) fi done +DJANGO_VERSIONS=("3.2" "4.0" "4.1" "4.2") # launch test on all versions only if we test 1 package if [[ ${#PACKAGES[@]} == 1 ]]; then LAUNCH_ALL_FLASK_VERSIONS=true LAUNCH_ALL_SQLALCHEMY_VERSIONS=true + LAUNCH_ALL_DJANGO_VERSIONS=true else LAUNCH_ALL_FLASK_VERSIONS=false LAUNCH_ALL_SQLALCHEMY_VERSIONS=false + LAUNCH_ALL_DJANGO_VERSIONS=false fi -# LAUNCH_ALL_FLASK_VERSIONS=true -# LAUNCH_ALL_SQLALCHEMY_VERSIONS=true - eval "$(pyenv init -)" mkdir -p $ARTIFACT_DIR @@ -98,6 +98,14 @@ do echo "#--------- running tests with sqlalchemy==$version" $(which poetry) run coverage run -m pytest # run tests done + elif [[ ("$package" == "datasource_django" || "$package" == "django_agent") && $LAUNCH_ALL_DJANGO_VERSIONS == true ]] + then + for version in ${DJANGO_VERSIONS[@]} + do + pip install -q -U django==$version + echo "#--------- running tests with django==$version" + $(which poetry) run coverage run -m pytest # run tests + done else echo "# running tests" From d2d1c5accb2ca53df67dc086e810afdefbd2671c Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Wed, 22 Nov 2023 18:08:43 +0100 Subject: [PATCH 18/19] chore(django_datasource): fix linting --- .../forestadmin/datasource_django/collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datasource_django/forestadmin/datasource_django/collection.py b/src/datasource_django/forestadmin/datasource_django/collection.py index 7790d5300..b00b48c6d 100644 --- a/src/datasource_django/forestadmin/datasource_django/collection.py +++ b/src/datasource_django/forestadmin/datasource_django/collection.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional +from typing import List, Optional from django.db.models import Model from forestadmin.agent_toolkit.utils.context import User From 2990aa38b121e8a5104249a2d0b70ddfda4010ba Mon Sep 17 00:00:00 2001 From: Julien Barreau Date: Thu, 23 Nov 2023 11:16:39 +0100 Subject: [PATCH 19/19] chore(django_agent): add dependency to django-cors-headers --- src/django_agent/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/django_agent/pyproject.toml b/src/django_agent/pyproject.toml index ebb193976..37c138bf8 100644 --- a/src/django_agent/pyproject.toml +++ b/src/django_agent/pyproject.toml @@ -18,6 +18,7 @@ tzdata = "~=2022.6" forestadmin-agent-toolkit = "1.2.0-beta.1" forestadmin-datasource-django = "1.2.0-beta.1" django = ">=3.2" +django-cors-headers = ">=3.8" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "test_project_agent.settings"