From c3c19a2620188db0bf372d37b179a77774c94dd4 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Sat, 13 May 2023 14:59:39 +0200 Subject: [PATCH 1/4] add charts support --- admin_ui/package-lock.json | 79 ++++++++++++- admin_ui/package.json | 3 + admin_ui/src/components/ChartsNav.vue | 31 +++++ admin_ui/src/components/ChartsPage.vue | 44 +++++++ admin_ui/src/components/SidebarNav.vue | 27 ++++- admin_ui/src/fontawesome.ts | 2 + admin_ui/src/interfaces.ts | 7 ++ admin_ui/src/main.ts | 5 +- admin_ui/src/router.ts | 7 ++ admin_ui/src/store.ts | 15 +++ admin_ui/src/views/GetCharts.vue | 27 +++++ docs/source/charts/examples/app.py | 38 ++++++ docs/source/charts/examples/home/tables.py | 9 ++ docs/source/charts/images/chart.png | Bin 0 -> 19323 bytes docs/source/charts/images/charts_sidebar.png | Bin 0 -> 26551 bytes docs/source/charts/index.rst | 32 +++++ docs/source/index.rst | 1 + piccolo_admin/endpoints.py | 117 ++++++++++++++++++- piccolo_admin/example.py | 17 +++ piccolo_admin/translations/data.py | 12 ++ tests/test_endpoints.py | 83 ++++++++++++- 21 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 admin_ui/src/components/ChartsNav.vue create mode 100644 admin_ui/src/components/ChartsPage.vue create mode 100644 admin_ui/src/views/GetCharts.vue create mode 100644 docs/source/charts/examples/app.py create mode 100644 docs/source/charts/examples/home/tables.py create mode 100644 docs/source/charts/images/chart.png create mode 100644 docs/source/charts/images/charts_sidebar.png create mode 100644 docs/source/charts/index.rst diff --git a/admin_ui/package-lock.json b/admin_ui/package-lock.json index 3c8d1c1f..74bdf496 100644 --- a/admin_ui/package-lock.json +++ b/admin_ui/package-lock.json @@ -14,13 +14,16 @@ "@fortawesome/vue-fontawesome": "^0.1.10", "@types/js-cookie": "^2.2.6", "axios": "^0.21.2", + "chart.js": "^4.3.0", "core-js": "^3.6.5", "flatpickr": "^4.6.9", + "highcharts": "^11.0.1", "is-svg": "^4.3.1", "js-cookie": "^2.2.1", "json-bigint": "^1.0.0", "ssri": "^8.0.1", "vue": "^2.7.14", + "vue-chartkick": "^0.6.1", "vue-class-component": "^7.2.6", "vue-flatpickr-component": "^8.1.7", "vue-i18n": "^8.27.2", @@ -2431,6 +2434,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@mdx-js/mdx": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", @@ -8860,6 +8868,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chart.js": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz", + "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=7" + } + }, + "node_modules/chartkick": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/chartkick/-/chartkick-3.2.1.tgz", + "integrity": "sha512-zV0kUeZNqrX28AmPt10QEDXHKadbVFOTAFkCMyJifHzGFkKzGCDXxVR8orZ0fC1HbePzRn5w6kLCOVxDQbMUCg==" + }, "node_modules/check-types": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz", @@ -10750,7 +10774,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -13824,6 +13847,11 @@ "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", "dev": true }, + "node_modules/highcharts": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-11.0.1.tgz", + "integrity": "sha512-KRwNwm6kJsp3JxNSpORuWLzvLnPuqCR3n0qbGjw+0m6MIxrlkUWnFaZJ2uXjMur6j3RUC66te36tldlSheYydQ==" + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -23583,6 +23611,19 @@ "csstype": "^3.1.0" } }, + "node_modules/vue-chartkick": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/vue-chartkick/-/vue-chartkick-0.6.1.tgz", + "integrity": "sha512-upUJKnx3irrRiMWudCkEVRpFvfy1hiVDKqv1e11Ap0qcFYCHJ231+SRDmQPcCypnKhfwc/Tm6REvsHkqRfc+Zg==", + "dependencies": { + "chartkick": "^3.2.0", + "deep-equal": "^1.0.1", + "deepmerge": "^4.2.0" + }, + "peerDependencies": { + "vue": ">=2.0.0" + } + }, "node_modules/vue-class-component": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.6.tgz", @@ -27495,6 +27536,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "@mdx-js/mdx": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz", @@ -32466,6 +32512,19 @@ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", "dev": true }, + "chart.js": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz", + "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==", + "requires": { + "@kurkle/color": "^0.3.0" + } + }, + "chartkick": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/chartkick/-/chartkick-3.2.1.tgz", + "integrity": "sha512-zV0kUeZNqrX28AmPt10QEDXHKadbVFOTAFkCMyJifHzGFkKzGCDXxVR8orZ0fC1HbePzRn5w6kLCOVxDQbMUCg==" + }, "check-types": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz", @@ -33935,8 +33994,7 @@ "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" }, "default-browser-id": { "version": "1.0.4", @@ -36320,6 +36378,11 @@ "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", "dev": true }, + "highcharts": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-11.0.1.tgz", + "integrity": "sha512-KRwNwm6kJsp3JxNSpORuWLzvLnPuqCR3n0qbGjw+0m6MIxrlkUWnFaZJ2uXjMur6j3RUC66te36tldlSheYydQ==" + }, "highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -44144,6 +44207,16 @@ } } }, + "vue-chartkick": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/vue-chartkick/-/vue-chartkick-0.6.1.tgz", + "integrity": "sha512-upUJKnx3irrRiMWudCkEVRpFvfy1hiVDKqv1e11Ap0qcFYCHJ231+SRDmQPcCypnKhfwc/Tm6REvsHkqRfc+Zg==", + "requires": { + "chartkick": "^3.2.0", + "deep-equal": "^1.0.1", + "deepmerge": "^4.2.0" + } + }, "vue-class-component": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.6.tgz", diff --git a/admin_ui/package.json b/admin_ui/package.json index 4f34b8cf..53bedc60 100644 --- a/admin_ui/package.json +++ b/admin_ui/package.json @@ -18,13 +18,16 @@ "@fortawesome/vue-fontawesome": "^0.1.10", "@types/js-cookie": "^2.2.6", "axios": "^0.21.2", + "chart.js": "^4.3.0", "core-js": "^3.6.5", "flatpickr": "^4.6.9", + "highcharts": "^11.0.1", "is-svg": "^4.3.1", "js-cookie": "^2.2.1", "json-bigint": "^1.0.0", "ssri": "^8.0.1", "vue": "^2.7.14", + "vue-chartkick": "^0.6.1", "vue-class-component": "^7.2.6", "vue-flatpickr-component": "^8.1.7", "vue-i18n": "^8.27.2", diff --git a/admin_ui/src/components/ChartsNav.vue b/admin_ui/src/components/ChartsNav.vue new file mode 100644 index 00000000..3f2c1d70 --- /dev/null +++ b/admin_ui/src/components/ChartsNav.vue @@ -0,0 +1,31 @@ + + + \ No newline at end of file diff --git a/admin_ui/src/components/ChartsPage.vue b/admin_ui/src/components/ChartsPage.vue new file mode 100644 index 00000000..0a7ae2fd --- /dev/null +++ b/admin_ui/src/components/ChartsPage.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/admin_ui/src/components/SidebarNav.vue b/admin_ui/src/components/SidebarNav.vue index b715a428..09c9d195 100644 --- a/admin_ui/src/components/SidebarNav.vue +++ b/admin_ui/src/components/SidebarNav.vue @@ -33,6 +33,23 @@

+ +

+ {{ $t("Charts") }} + + + + +

+ @@ -40,21 +57,27 @@ import Vue from "vue" import TableNav from "./TableNav.vue" import FormNav from "./FormNav.vue" +import ChartsNav from "./ChartsNav.vue" export default Vue.extend({ data() { return { isHiddenTables: false, - isHiddenForms: false + isHiddenForms: false, + isHiddenCharts: false } }, components: { TableNav, - FormNav + FormNav, + ChartsNav }, computed: { formConfigs() { return this.$store.state.formConfigs + }, + chartConfigs() { + return this.$store.state.chartConfigs } } }) diff --git a/admin_ui/src/fontawesome.ts b/admin_ui/src/fontawesome.ts index 91ee50c8..9c19a5a4 100644 --- a/admin_ui/src/fontawesome.ts +++ b/admin_ui/src/fontawesome.ts @@ -11,6 +11,7 @@ import { faBars, faCaretUp, faCaretDown, + faChartBar, faCheck, faCircleNotch, faCogs, @@ -53,6 +54,7 @@ library.add( faBars, faCaretUp, faCaretDown, + faChartBar, faCheck, faCircleNotch, faCogs, diff --git a/admin_ui/src/interfaces.ts b/admin_ui/src/interfaces.ts index 358c14e2..dfad87d1 100644 --- a/admin_ui/src/interfaces.ts +++ b/admin_ui/src/interfaces.ts @@ -145,3 +145,10 @@ export interface FormConfig { slug: string description: string } + +export interface ChartConfig { + title: string + chart_slug: string + chart_type: string + data: any +} diff --git a/admin_ui/src/main.ts b/admin_ui/src/main.ts index 3197ba20..d69a17a0 100644 --- a/admin_ui/src/main.ts +++ b/admin_ui/src/main.ts @@ -4,6 +4,8 @@ import router from "./router" import store from "./store" import i18n from "./i18n" import "./fontawesome" +import Chartkick from 'vue-chartkick' +import Highcharts from 'highcharts' /*****************************************************************************/ @@ -30,7 +32,7 @@ axios.defaults.transformResponse = [ if (typeof data === "string") { try { data = JSONBig.parse(data) - } catch (e) {} + } catch (e) { } } return data } @@ -45,6 +47,7 @@ Vue.filter("readable", function (value) { /*****************************************************************************/ Vue.config.productionTip = false +Vue.use(Chartkick.use(Highcharts)) new Vue({ i18n, diff --git a/admin_ui/src/router.ts b/admin_ui/src/router.ts index e68d53f2..0eb3bc40 100644 --- a/admin_ui/src/router.ts +++ b/admin_ui/src/router.ts @@ -8,6 +8,7 @@ import Login from './views/Login.vue' import ChangePassword from './views/ChangePassword.vue' import RowListing from './views/RowListing.vue' import AddForm from './views/AddForm.vue' +import GetCharts from './views/GetCharts.vue' Vue.use(Router) @@ -37,6 +38,12 @@ export default new Router({ component: AddForm, props: true }, + { + path: '/charts/:chartSlug/', + name: 'getCharts', + component: GetCharts, + props: true + }, { path: '/:tableName/', name: 'rowListing', diff --git a/admin_ui/src/store.ts b/admin_ui/src/store.ts index 262044f4..7e81da1b 100644 --- a/admin_ui/src/store.ts +++ b/admin_ui/src/store.ts @@ -34,6 +34,7 @@ export default new Vuex.Store({ tableNames: [], tableGroups: {}, formConfigs: [] as i.FormConfig[], + chartConfigs: [] as i.ChartConfig[], user: undefined, loadingStatus: false }, @@ -62,6 +63,9 @@ export default new Vuex.Store({ updateFormSchema(state, formSchema) { state.formSchema = formSchema }, + updateChartConfigs(state, value) { + state.chartConfigs = value + }, updateApiResponseMessage(state, message: i.APIResponseMessage) { state.apiResponseMessage = message }, @@ -113,6 +117,17 @@ export default new Vuex.Store({ context.commit("updateFormSchema", response.data) return response }, + async fetchChartConfigs(context) { + const response = await axios.get(`${BASE_URL}charts/`) + context.commit("updateChartConfigs", response.data) + }, + async fetchChartConfig(context, chartSlug: string) { + const response = await axios.get( + `${BASE_URL}charts/${chartSlug}/` + ) + context.commit("updateChartConfigs", response.data) + return response + }, /*********************************************************************/ diff --git a/admin_ui/src/views/GetCharts.vue b/admin_ui/src/views/GetCharts.vue new file mode 100644 index 00000000..51ab9ab8 --- /dev/null +++ b/admin_ui/src/views/GetCharts.vue @@ -0,0 +1,27 @@ + + + + + + + diff --git a/docs/source/charts/examples/app.py b/docs/source/charts/examples/app.py new file mode 100644 index 00000000..c0cb9a2a --- /dev/null +++ b/docs/source/charts/examples/app.py @@ -0,0 +1,38 @@ +from fastapi import FastAPI +from fastapi.routing import Mount +from home.tables import Director, Movie + +from piccolo_admin.endpoints import ChartConfig, create_admin + + +# count directors movies +async def director_movie_count(): + movies = await Movie.select( + Movie.director.name.as_alias("director"), + Count(Movie.id), + ).group_by(Movie.director) + # Flatten the response so it's a list of lists + # like [['George Lucas', 3], ...] + return [[i["director"], i["count"]] for i in movies] + + +director_chart = ChartConfig( + title="Movie count", + chart_type="Pie", + data=director_movie_count, +) + + +app = FastAPI( + routes=[ + Mount( + "/admin/", + create_admin( + tables=[Director, Movie], + charts=[director_chart], + ), + ), + ], +) + +# For Starlette it is identical, just `app = Starlette(...)` diff --git a/docs/source/charts/examples/home/tables.py b/docs/source/charts/examples/home/tables.py new file mode 100644 index 00000000..dd033e47 --- /dev/null +++ b/docs/source/charts/examples/home/tables.py @@ -0,0 +1,9 @@ +from piccolo.columns.column_types import Varchar, ForeignKey +from piccolo.table import Table + +class Director(Table): + name = Varchar() + +class Movie(Table): + title = Varchar() + director = ForeignKey(references=Director) \ No newline at end of file diff --git a/docs/source/charts/images/chart.png b/docs/source/charts/images/chart.png new file mode 100644 index 0000000000000000000000000000000000000000..641fd4f696405903592ccf6c8a4a41d6ec5a2c08 GIT binary patch literal 19323 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 literal 0 HcmV?d00001 diff --git a/docs/source/charts/images/charts_sidebar.png b/docs/source/charts/images/charts_sidebar.png new file mode 100644 index 0000000000000000000000000000000000000000..f1202fdbd16933f4fa9fed6bda65dd97f09a89f9 GIT binary patch literal 26551 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Μ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 k|DXPUN$mT7^F>0(Z-HdGPyhe` literal 0 HcmV?d00001 diff --git a/docs/source/charts/index.rst b/docs/source/charts/index.rst new file mode 100644 index 00000000..5ea98881 --- /dev/null +++ b/docs/source/charts/index.rst @@ -0,0 +1,32 @@ +.. _Charts: + +Charts +====== + +Piccolo Admin can display different types of charts based on yours +data. Five chart types are supported: ``Pie``, ``Line``, ``Column``, +``Bar`` and ``Area``. + +Here's an example of charts usage, using FastAPI: + +.. literalinclude:: ./examples/app.py + +Piccolo Admin will then show a charts in the UI. + +.. image:: ./images/charts_sidebar.png + +.. image:: ./images/chart.png + +.. warning:: + + The data format must be a ``list of lists`` + (eg. ``[["Male", 7], ["Female", 3]]``). + +------------------------------------------------------------------------------- + +Source +------ + +.. currentmodule:: piccolo_admin.endpoints + +.. autoclass:: ChartConfig diff --git a/docs/source/index.rst b/docs/source/index.rst index 8ea42457..74def468 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -46,6 +46,7 @@ Table of Contents ../help_text/index ../table_config/index ../custom_forms/index + ../charts/index ../actions/index ../media_storage/index ../internationalization/index diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index ba02de98..0c951fc0 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -349,6 +349,60 @@ class FormConfigResponseModel(BaseModel): description: t.Optional[str] = None +@dataclass +class ChartConfig: + """ + Used to specify charts, which are passed into ``create_admin``. + + :param title: + This will be displayed in the UI in the sidebar. + :param chart_slug: + This determines which chart will be displayed. + :param chart_type: + Available chart types. There are five types: ``Pie``, ``Line``, + ``Column``, ``Bar`` and ``Area``. + :param data: + The data to be passed to the admin ui. The data format must be + a ``list of lists`` (eg. ``[["Male", 7], ["Female", 3]]``). + + Here's a full example: + + .. code-block:: python + + async def director_movie_count(): + movies = await Movie.select( + Movie.director.name.as_alias("director"), + Count(Movie.id) + ).group_by( + Movie.director + ) + # Flatten the response so it's a list of lists + # like [['George Lucas', 3], ...] + return [[i['director'], i['count']] for i in movies] + + director_chart = ChartConfig( + title='Movie count', + chart_type="Pie", # or Bar or Line etc. + data=director_movie_count, + + create_admin(charts=[director_chart]) + + """ + + def __init__(self, title: str, chart_type: str, data: t.List[t.Any]): + self.title = title + self.chart_slug = self.title.replace(" ", "-").lower() + self.chart_type = chart_type + self.data = data + + +class ChartConfigResponseModel(BaseModel): + title: str + chart_slug: str + chart_type: str + data: t.List[t.Any] + + def handle_auth_exception(request: Request, exc: Exception): return JSONResponse({"error": "Auth failed"}, status_code=401) @@ -401,6 +455,7 @@ def __init__( translations: t.List[Translation] = None, allowed_hosts: t.Sequence[str] = [], debug: bool = False, + charts: t.List[ChartConfig] = [], ) -> None: super().__init__( title=site_name, @@ -484,6 +539,10 @@ def __init__( self.site_name = site_name self.forms = forms self.read_only = read_only + self.charts = charts + self.chart_config_map = { + chart.chart_slug: chart for chart in self.charts + } self.form_config_map = {form.slug: form for form in self.forms} with open(os.path.join(ASSET_PATH, "index.html")) as f: @@ -584,6 +643,22 @@ def __init__( tags=["Forms"], ) + private_app.add_api_route( + path="/charts/", + endpoint=self.get_charts, # type: ignore + methods=["GET"], + tags=["Charts"], + response_model=t.List[ChartConfigResponseModel], + ) + + private_app.add_api_route( + path="/charts/{chart_slug:str}/", + endpoint=self.get_single_chart, # type: ignore + methods=["GET"], + tags=["Charts"], + response_model=ChartConfigResponseModel, + ) + private_app.add_api_route( path="/user/", endpoint=self.get_user, # type: ignore @@ -836,6 +911,38 @@ def get_user(self, request: Request) -> UserResponseModel: user_id=str(request.user.user_id), ) + ########################################################################### + # Custom charts + + def get_charts(self) -> t.List[ChartConfigResponseModel]: + """ + Returns all charts registered with the admin. + """ + return [ + ChartConfigResponseModel( + title=chart.title, + chart_slug=chart.chart_slug, + chart_type=chart.chart_type, + data=chart.data, + ) + for chart in self.charts + ] + + def get_single_chart(self, chart_slug: str) -> ChartConfigResponseModel: + """ + Returns single chart. + """ + chart = self.chart_config_map.get(chart_slug, None) + if chart is None: + raise HTTPException(status_code=404, detail="No such chart found") + else: + return ChartConfigResponseModel( + title=chart.title, + chart_slug=chart.chart_slug, + chart_type=chart.chart_type, + data=chart.data, + ) + ########################################################################### # Custom forms @@ -845,7 +952,9 @@ def get_forms(self) -> t.List[FormConfigResponseModel]: """ return [ FormConfigResponseModel( - name=form.name, slug=form.slug, description=form.description + name=form.name, + slug=form.slug, + description=form.description, ) for form in self.forms ] @@ -1034,6 +1143,7 @@ def create_admin( auto_include_related: bool = True, allowed_hosts: t.Sequence[str] = [], debug: bool = False, + charts: t.List[ChartConfig] = [], ): """ :param tables: @@ -1136,6 +1246,10 @@ def create_admin( If ``True``, debug mode is enabled. Any unhandled exceptions will return a stack trace, rather than a generic 500 error. Don't use this in production! + :param charts: + For each :class:`ChartConfig ` + specified, a chart will automatically be rendered in the user interface, + accessible via the sidebar. """ # noqa: E501 auth_table = auth_table or BaseUser @@ -1181,4 +1295,5 @@ def create_admin( translations=translations, allowed_hosts=allowed_hosts, debug=debug, + charts=charts, ) diff --git a/piccolo_admin/example.py b/piccolo_admin/example.py index 8f49d8f6..6e1ff36e 100644 --- a/piccolo_admin/example.py +++ b/piccolo_admin/example.py @@ -47,6 +47,7 @@ from starlette.requests import Request from piccolo_admin.endpoints import ( + ChartConfig, FormConfig, OrderBy, TableConfig, @@ -440,6 +441,22 @@ def booking_endpoint(request: Request, data: BookingModel) -> str: ], auth_table=User, session_table=Sessions, + charts=[ + ChartConfig( + title="Movie count", + chart_type="Pie", + data=[ + ["George Lucas", 4], + ["Peter Jackson", 6], + ["Ron Howard", 1], + ], + ), + ChartConfig( + title="Director gender", + chart_type="Column", + data=[["Male", 7], ["Female", 3]], + ), + ], ) diff --git a/piccolo_admin/translations/data.py b/piccolo_admin/translations/data.py index c8f41606..4f0c0f19 100644 --- a/piccolo_admin/translations/data.py +++ b/piccolo_admin/translations/data.py @@ -35,6 +35,7 @@ "Back to home page": "Back to home page", "Back": "Back", "Change password": "Change password", + "Charts": "Charts", "Clear filters": "Clear filters", "Close": "Close", "Create": "Create", @@ -104,6 +105,7 @@ "Back to home page": "Yn ôl i'r dudalen gartref", "Back": "Ol", "Change password": "Newid cyfrinair", + "Charts": "Siartiau", "Clear filters": "Clirio hidlwyr", "Close": "Cau", "Create": "Creu", @@ -172,6 +174,7 @@ "Back to home page": "Vrati se na početnu stranicu", "Back": "Natrag", "Change password": "Promijeni lozinku", + "Charts": "Grafikoni", "Clear filters": "Obriši filtere", "Close": "Zatvori", "Create": "Kreiraj", @@ -241,6 +244,7 @@ "Back to home page": "Voltar à página inicial", "Back": "Voltar atrás", "Change password": "Mudar senha", + "Charts": "Gráficos", "Clear filters": "Limpar Filtros", "Close": "Fechar", "Create": "Criar", @@ -310,6 +314,7 @@ "Back to home page": "Zurück zur Startseite", "Back": "Zurück", "Change password": "Passwort ändern", + "Charts": "Diagramme", "Clear filters": "Filter löschen", "Close": "Schließen", "Create": "Anlegen", @@ -379,6 +384,7 @@ "Back to home page": "Retour à la page d'accueil", "Back": "Retour", "Change password": "Changer le mot de passe", + "Charts": "Graphiques", "Clear filters": "Supprimer les filtres", "Close": "Fermer", "Create": "Créer", @@ -448,6 +454,7 @@ "Back to home page": "Volver a la página de inicio", "Back": "atrás", "Change password": "Cambia la contraseña", + "Charts": "Gráficos", "Clear filters": "Eliminar filtros", "Close": "Cerca", "Create": "Crear", @@ -516,6 +523,7 @@ "Back to home page": "Takaisin pääsivulle", "Back": "Takaisin", "Change password": "Vaihda salasana", + "Charts": "Kaaviot", "Clear filters": "Nollaa suodattimet", "Close": "Sulje", "Create": "Luo", @@ -584,6 +592,7 @@ "Back to home page": "Вернуться на главную страницу", "Back": "Назад", "Change password": "Сменить пароль", + "Charts": "Графики", "Clear filters": "Сбросить фильтры", "Close": "Закрыть", "Create": "Создать", @@ -652,6 +661,7 @@ "Back to home page": "Повернутися на головну сторінку", "Back": "Назад", "Change password": "Змінити пароль", + "Charts": "Діаграми", "Clear filters": "Очистити фільтри", "Close": "Закрити", "Create": "Створити", @@ -720,6 +730,7 @@ "Back to home page": "返回主页", "Back": "返回", "Change password": "修改密码", + "Charts": "图表", "Clear filters": "清除过滤器", "Close": "关闭", "Create": "创建", @@ -789,6 +800,7 @@ "Back to home page": "Anasayfaya geri dön", "Back": "Geri dön", "Change password": "Şifreyi değiştir", + "Charts": "Grafikler", "Clear filters": "Filtreleri temizle", "Close": "Kapat", "Create": "Oluştur", diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 2d4ee44b..ee002812 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -801,7 +801,6 @@ def test_get_language_failed(self): class TestHooks(TestCase): - credentials = {"username": "Bob", "password": "bob123"} def setUp(self): @@ -874,7 +873,6 @@ def hook(row, mock: MagicMock = mock): class TestValidators(TestCase): - credentials = {"username": "Bob", "password": "bob123"} def setUp(self): @@ -930,3 +928,84 @@ def post_single_validator(piccolo_crud, request): ) self.assertEqual(response.status_code, 403) self.assertEqual(response.content, b'{"detail":"Not allowed!"}') + + +class TestCharts(TestCase): + credentials = {"username": "Bob", "password": "bob123"} + + def setUp(self): + create_db_tables_sync(SessionsBase, BaseUser, if_not_exists=True) + BaseUser.create_user_sync( + **self.credentials, active=True, admin=True, superuser=True + ) + + def tearDown(self): + SessionsBase.alter().drop_table().run_sync() + BaseUser.alter().drop_table().run_sync() + + def test_charts(self): + """ + Make sure the charts listing can be retrieved. + """ + client = TestClient(APP) + + # To get a CSRF cookie + response = client.get("/") + csrftoken = response.cookies["csrftoken"] + + # Login + payload = dict(csrftoken=csrftoken, **self.credentials) + client.post( + "/public/login/", + json=payload, + headers={"X-CSRFToken": csrftoken}, + ) + + ####################################################################### + # List all forms + + response = client.get("/api/charts/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + [ + { + "title": "Movie count", + "chart_slug": "movie-count", + "chart_type": "Pie", + "data": [ + ["George Lucas", 4], + ["Peter Jackson", 6], + ["Ron Howard", 1], + ], + }, + { + "title": "Director gender", + "chart_slug": "director-gender", + "chart_type": "Column", + "data": [["Male", 7], ["Female", 3]], + }, + ], + ) + + ####################################################################### + # Now get the ChartConfig for a single chart + + response = client.get("/api/charts/movie-count/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "title": "Movie count", + "chart_slug": "movie-count", + "chart_type": "Pie", + "data": [ + ["George Lucas", 4], + ["Peter Jackson", 6], + ["Ron Howard", 1], + ], + }, + ) + response = client.get("/api/charts/no-such-chart/") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.content, b'{"detail":"No such chart found"}') From cafb5c4f89d84ed783de87a0141b40508de8aa00 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Fri, 26 May 2023 15:41:19 +0200 Subject: [PATCH 2/4] fix Playwright tests --- e2e/test_codegen.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/e2e/test_codegen.py b/e2e/test_codegen.py index 6e2de28d..ca9ce269 100644 --- a/e2e/test_codegen.py +++ b/e2e/test_codegen.py @@ -37,8 +37,8 @@ def test_row_listing_filter(playwright: Playwright, dev_server) -> None: page.locator('input[name="username"]').press("Tab") page.locator('input[name="password"]').fill("piccolo123") page.locator('input[name="password"]').press("Enter") - page.get_by_role("link", name="director").click() - page.get_by_role("link", name="Show filters").click() + page.get_by_role("link", name="director", exact=True).click() + page.get_by_role("link", name="Show filters", exact=True).click() page.locator('input[name="name"]').click() page.locator('input[name="name"]').fill("Howard") page.locator('input[name="name"]').press("Enter") @@ -108,8 +108,8 @@ def test_file_upload(playwright: Playwright, dev_server) -> None: page.locator('input[name="username"]').press("Tab") page.locator('input[name="password"]').fill("piccolo123") page.locator('input[name="password"]').press("Enter") - page.get_by_role("link", name="director").click() - page.get_by_role("link", name="8").click() + page.get_by_role("link", name="director", exact=True).click() + page.get_by_role("link", name="8", exact=True).click() page.locator('input[type="file"]').click() page.locator('input[type="file"]').set_input_files( "./e2e/upload/piccolo.jpg" @@ -143,7 +143,7 @@ def test_bulk_update(playwright: Playwright, dev_server) -> None: page.locator('input[name="username"]').press("Tab") page.locator('input[name="password"]').fill("piccolo123") page.locator('input[name="password"]').press("Enter") - page.get_by_role("link", name="director").click() + page.get_by_role("link", name="director", exact=True).click() page.locator("th").first.click() page.get_by_role("row", name="id Name Gender Photo").get_by_role( "checkbox" @@ -171,9 +171,9 @@ def test_table_crud(playwright: Playwright, dev_server) -> None: page.locator('input[name="username"]').press("Tab") page.locator('input[name="password"]').fill("piccolo123") page.locator('input[name="password"]').press("Enter") - page.get_by_role("link", name="director").click() - page.get_by_role("cell", name="8").click() - page.get_by_role("link", name="8").click() + page.get_by_role("link", name="director", exact=True).click() + page.get_by_role("cell", name="8", exact=True).click() + page.get_by_role("link", name="8", exact=True).click() page.locator('input[name="name"]').click() page.locator('input[name="name"]').fill("Ronald William Howard") page.locator('input[name="name"]').press("Enter") From 9ff72829871c7a12444730202468916e36551e99 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Sun, 28 May 2023 10:37:57 +0200 Subject: [PATCH 3/4] fix charts in dark mode --- admin_ui/src/components/TableNav.vue | 2 +- admin_ui/src/main.less | 23 +++++++++++++++++++ docs/source/charts/images/chart.png | Bin 19323 -> 11486 bytes docs/source/charts/images/charts_sidebar.png | Bin 26551 -> 27819 bytes 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/admin_ui/src/components/TableNav.vue b/admin_ui/src/components/TableNav.vue index 6133e410..3ed2b110 100644 --- a/admin_ui/src/components/TableNav.vue +++ b/admin_ui/src/components/TableNav.vue @@ -1,5 +1,5 @@