From 7474885f14ad6bd6c39338005f046deeabf5b47d Mon Sep 17 00:00:00 2001 From: Nikolai R Kristiansen Date: Sat, 19 Feb 2022 22:21:11 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20User=20product=20purchases=20over?= =?UTF-8?q?=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/users/UserDetail.module.css | 2 +- frontend/src/features/users/UserDetail.tsx | 58 ++++++++++++++++++- .../src/features/users/userDetail.graphql | 7 +++ frontend/src/types.ts | 17 +++++- main/api/types/users.py | 19 ++++++ main/models.py | 39 ++++++++++++- schema.graphql | 11 ++++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/users/UserDetail.module.css b/frontend/src/features/users/UserDetail.module.css index e21b4f8..3e20669 100644 --- a/frontend/src/features/users/UserDetail.module.css +++ b/frontend/src/features/users/UserDetail.module.css @@ -1,5 +1,5 @@ .userDetail { display: grid; gap: 1rem; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: 400px 1fr; } diff --git a/frontend/src/features/users/UserDetail.tsx b/frontend/src/features/users/UserDetail.tsx index a2ced35..10eab00 100644 --- a/frontend/src/features/users/UserDetail.tsx +++ b/frontend/src/features/users/UserDetail.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@apollo/client"; +import { Line } from "@nivo/line"; import React from "react"; import { useParams } from "react-router-dom"; @@ -14,6 +15,15 @@ export const UserDetail = () => { const user = data?.user as User; const profile = user?.profile as UserProfile; + // FIXME: types + const series: any[] = []; + (profile?.productOrderStats || [])?.forEach(({ productName: id, data }) => { + const lineData: any[] = []; + data.forEach((point, index) => { + lineData.push({ x: point.x, y: point.y + (lineData?.[index - 1]?.y || 0) }); + }); + series.push({ id, data: lineData }); + }); return (
@@ -47,7 +57,53 @@ export const UserDetail = () => {
-
TODO: produktgraf
+
+ ( +
+
+ {point.data.xFormatted} +
+
+ {" "} + {point.data.yFormatted} {point.serieId} +
+
+ )} + /> +
); }; diff --git a/frontend/src/features/users/userDetail.graphql b/frontend/src/features/users/userDetail.graphql index 837f170..d6f76ff 100644 --- a/frontend/src/features/users/userDetail.graphql +++ b/frontend/src/features/users/userDetail.graphql @@ -13,6 +13,13 @@ query userDetail($userId: ID!) { sum count } + productOrderStats { + productName + data { + x + y + } + } orderSumTotal } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e6fad2a..25c4239 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -142,6 +142,12 @@ export enum Ordering { Desc = 'DESC' } +export type Point = { + __typename?: 'Point'; + x: Scalars['String']; + y: Scalars['Int']; +}; + export type Product = { __typename?: 'Product'; id: Scalars['ID']; @@ -157,6 +163,12 @@ export type ProductFilter = { id?: InputMaybe; }; +export type ProductOrderStat = { + __typename?: 'ProductOrderStat'; + data: Array; + productName: Scalars['String']; +}; + export type Query = { __typename?: 'Query'; allOrders: Array; @@ -230,6 +242,7 @@ export type UserProfile = { image: Scalars['String']; lastPurchaseDate: Scalars['DateTime']; orderSumTotal: Scalars['Float']; + productOrderStats: Array; topMonths: Array; }; @@ -304,7 +317,7 @@ export type UserDetailQueryVariables = Exact<{ }>; -export type UserDetailQuery = { __typename?: 'Query', user: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number, image: string, lastPurchaseDate: any, orderSumTotal: number, topMonths: Array<{ __typename?: 'TopMonth', period: string, sum: number, count: number }> } | null } }; +export type UserDetailQuery = { __typename?: 'Query', user: { __typename?: 'User', id: string, firstName: string, lastName: string, profile?: { __typename?: 'UserProfile', id: string, balance: number, image: string, lastPurchaseDate: any, orderSumTotal: number, topMonths: Array<{ __typename?: 'TopMonth', period: string, sum: number, count: number }>, productOrderStats: Array<{ __typename?: 'ProductOrderStat', productName: string, data: Array<{ __typename?: 'Point', x: string, y: number }> }> } | null } }; export const CreateUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"firstName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lastName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"firstName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"firstName"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"lastName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lastName"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -316,4 +329,4 @@ export const AllUsersDocument = {"kind":"Document","definitions":[{"kind":"Opera export const CreateOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"customerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderLines"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderLineInput"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"isExternal"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"customerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"customerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderLines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderLines"}}},{"kind":"Argument","name":{"kind":"Name","value":"isExternal"},"value":{"kind":"Variable","name":{"kind":"Name","value":"isExternal"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"CreateOrderSuccess"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isExternal"}},{"kind":"Field","name":{"kind":"Name","value":"customer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderlines"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"unitPrice"}},{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceExt"}},{"kind":"Field","name":{"kind":"Name","value":"salePriceInt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderSum"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InsufficientFunds"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"amountLacking"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FormErrors"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"fields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const OrderStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"orderStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orderStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"yearly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"monthly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"daily"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"hourly"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]}}]} as unknown as DocumentNode; export const ProductStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"productStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"active"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allProducts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"active"},"value":{"kind":"Variable","name":{"kind":"Name","value":"active"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"userCounts"}}]}}]}}]} as unknown as DocumentNode; -export const UserDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"userDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}},{"kind":"Field","name":{"kind":"Name","value":"image"}},{"kind":"Field","name":{"kind":"Name","value":"lastPurchaseDate"}},{"kind":"Field","name":{"kind":"Name","value":"topMonths"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"sum"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderSumTotal"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const UserDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"userDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"profile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}},{"kind":"Field","name":{"kind":"Name","value":"image"}},{"kind":"Field","name":{"kind":"Name","value":"lastPurchaseDate"}},{"kind":"Field","name":{"kind":"Name","value":"topMonths"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"period"}},{"kind":"Field","name":{"kind":"Name","value":"sum"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"productOrderStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"orderSumTotal"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/main/api/types/users.py b/main/api/types/users.py index 916a6aa..995e4b9 100644 --- a/main/api/types/users.py +++ b/main/api/types/users.py @@ -17,6 +17,18 @@ class TopMonth: sum: int +@strawberry.type +class Point: + x: str + y: int + + +@strawberry.type +class ProductOrderStat: + product_name: str + data: list[Point] + + @strawberry_django.type(models.UserProfile) class UserProfile: id: auto @@ -29,6 +41,13 @@ class UserProfile: def top_months(self) -> list[TopMonth]: return [TopMonth(**month) for month in self.top_months()] + @strawberry.field() + def product_order_stats(self) -> list[ProductOrderStat]: + return [ + ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]]) + for stat in self.product_order_stats().as_series() + ] + @strawberry_django.type(UserModel) class User: diff --git a/main/models.py b/main/models.py index 6218124..183a33c 100755 --- a/main/models.py +++ b/main/models.py @@ -1,11 +1,20 @@ import hashlib from datetime import datetime +from itertools import groupby from typing import Type from django.conf import settings from django.db import models -from django.db.models import Count, Sum -from django.db.models.functions import Extract, ExtractHour, ExtractIsoWeekDay, ExtractMonth, ExtractYear, TruncMonth +from django.db.models import Count, F, Sum +from django.db.models.functions import ( + Extract, + ExtractHour, + ExtractIsoWeekDay, + ExtractMonth, + ExtractYear, + TruncDay, + TruncMonth, +) EXTERNAL_USER = "Ekstern" @@ -115,12 +124,35 @@ class Order(models.Model): return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr" +class OrderLineManager(models.QuerySet): + def product_order_stats(self): + return ( + self.annotate(date=TruncDay("order__created")) + .values("date", product_name=F("product__name")) + .annotate(count=Sum("amount")) + .order_by("date") + ) + + def as_series(self): + datas = list(self) + datas.sort(key=lambda x: x["product_name"]) + return [ + { + "product_name": key, + "data": [{"x": item["date"].date().isoformat(), "y": item["count"]} for item in list(group)], + } + for key, group in groupby(datas, key=lambda x: x["product_name"]) + ] + + class OrderLine(models.Model): order = models.ForeignKey("main.Order", on_delete=models.CASCADE, related_name="orderlines") product = models.ForeignKey("main.Product", on_delete=models.CASCADE, related_name="orderlines") amount = models.IntegerField() unit_price = models.FloatField(verbose_name="Enhetspris") + objects = OrderLineManager.as_manager() + @property def price(self): return self.amount * self.unit_price @@ -192,6 +224,9 @@ class UserProfile(models.Model): .order_by("-sum")[:limit] ) + def product_order_stats(self): + return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats() + def order_sum_total(self) -> float: return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0 diff --git a/schema.graphql b/schema.graphql index a0c7792..eee6dd4 100644 --- a/schema.graphql +++ b/schema.graphql @@ -102,6 +102,11 @@ enum Ordering { DESC } +type Point { + x: String! + y: Int! +} + type Product { id: ID! name: String! @@ -116,6 +121,11 @@ input ProductFilter { active: Boolean } +type ProductOrderStat { + productName: String! + data: [Point!]! +} + type Query { transactionList(pagination: OffsetPaginationInput, order: TransactionOrdering): [Transaction!]! allProducts(filters: ProductFilter): [Product!]! @@ -162,5 +172,6 @@ type UserProfile { image: String! lastPurchaseDate: DateTime! orderSumTotal: Float! + productOrderStats: [ProductOrderStat!]! topMonths: [TopMonth!]! } \ No newline at end of file