forked from mirrors/homebox
feat: group statistics endpoint (#123)
* group statistics endpoint * remove item store * return possible errors * add statistics tests
This commit is contained in:
parent
a886fa86ca
commit
7e0f1fac23
13 changed files with 201 additions and 74 deletions
|
@ -24,6 +24,26 @@ type (
|
|||
}
|
||||
)
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group
|
||||
// @Tags Group
|
||||
// @Produce json
|
||||
// @Success 200 {object} repo.GroupStatistics
|
||||
// @Router /v1/groups/statistics [Get]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupStatistics() server.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
stats, err := ctrl.repo.Groups.GroupStatistics(ctx, ctx.GID)
|
||||
if err != nil {
|
||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return server.Respond(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGroupGet godoc
|
||||
// @Summary Get the current user's group
|
||||
// @Tags Group
|
||||
|
|
|
@ -76,6 +76,7 @@ func (a *app) mountRoutes(repos *repo.AllRepos) {
|
|||
a.server.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword(), a.mwAuthToken)
|
||||
|
||||
a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), a.mwAuthToken)
|
||||
a.server.Get(v1Base("/groups/statistics"), v1Ctrl.HandleGroupStatistics(), a.mwAuthToken)
|
||||
|
||||
// TODO: I don't like /groups being the URL for users
|
||||
a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), a.mwAuthToken)
|
||||
|
|
|
@ -113,6 +113,30 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
],
|
||||
"summary": "Get the current user's group",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.GroupStatistics"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -1178,6 +1202,23 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"repo.GroupStatistics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalItems": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalLabels": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalLocations": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalUsers": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.GroupUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -105,6 +105,30 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/groups/statistics": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Group"
|
||||
],
|
||||
"summary": "Get the current user's group",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.GroupStatistics"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -1170,6 +1194,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"repo.GroupStatistics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalItems": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalLabels": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalLocations": {
|
||||
"type": "integer"
|
||||
},
|
||||
"totalUsers": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.GroupUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -22,6 +22,17 @@ definitions:
|
|||
updatedAt:
|
||||
type: string
|
||||
type: object
|
||||
repo.GroupStatistics:
|
||||
properties:
|
||||
totalItems:
|
||||
type: integer
|
||||
totalLabels:
|
||||
type: integer
|
||||
totalLocations:
|
||||
type: integer
|
||||
totalUsers:
|
||||
type: integer
|
||||
type: object
|
||||
repo.GroupUpdate:
|
||||
properties:
|
||||
currency:
|
||||
|
@ -560,6 +571,20 @@ paths:
|
|||
summary: Get the current user
|
||||
tags:
|
||||
- Group
|
||||
/v1/groups/statistics:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/repo.GroupStatistics'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Get the current user's group
|
||||
tags:
|
||||
- Group
|
||||
/v1/items:
|
||||
get:
|
||||
parameters:
|
||||
|
|
|
@ -72,13 +72,11 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
@ -90,8 +88,6 @@ github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
|||
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
|
||||
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
|
|
@ -41,6 +41,12 @@ type (
|
|||
Uses int `json:"uses"`
|
||||
Group Group `json:"group"`
|
||||
}
|
||||
GroupStatistics struct {
|
||||
TotalUsers int `json:"totalUsers"`
|
||||
TotalItems int `json:"totalItems"`
|
||||
TotalLocations int `json:"totalLocations"`
|
||||
TotalLabels int `json:"totalLabels"`
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -70,6 +76,25 @@ func mapToGroupInvitation(g *ent.GroupInvitationToken) GroupInvitation {
|
|||
}
|
||||
}
|
||||
|
||||
func (r *GroupRepository) GroupStatistics(ctx context.Context, GID uuid.UUID) (GroupStatistics, error) {
|
||||
q := `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE group_users = ?) AS total_users,
|
||||
(SELECT COUNT(*) FROM items WHERE group_items = ? AND items.archived = false) AS total_items,
|
||||
(SELECT COUNT(*) FROM locations WHERE group_locations = ?) AS total_locations,
|
||||
(SELECT COUNT(*) FROM labels WHERE group_labels = ?) AS total_labels
|
||||
`
|
||||
var stats GroupStatistics
|
||||
row := r.db.Sql().QueryRowContext(ctx, q, GID, GID, GID, GID)
|
||||
|
||||
err := row.Scan(&stats.TotalUsers, &stats.TotalItems, &stats.TotalLocations, &stats.TotalLabels)
|
||||
if err != nil {
|
||||
return GroupStatistics{}, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, error) {
|
||||
return mapToGroupErr(r.db.Group.Create().
|
||||
SetName(name).
|
||||
|
|
|
@ -31,3 +31,16 @@ func Test_Group_Update(t *testing.T) {
|
|||
assert.Equal(t, "test2", g.Name)
|
||||
assert.Equal(t, "EUR", g.Currency)
|
||||
}
|
||||
|
||||
func Test_Group_GroupStatistics(t *testing.T) {
|
||||
useItems(t, 20)
|
||||
useLabels(t, 20)
|
||||
|
||||
stats, err := tRepos.Groups.GroupStatistics(context.Background(), tGroup.ID)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 20, stats.TotalItems)
|
||||
assert.Equal(t, 20, stats.TotalLabels)
|
||||
assert.Equal(t, 1, stats.TotalUsers)
|
||||
assert.Equal(t, 1, stats.TotalLocations)
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useItemStore } from "~~/stores/items";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
|
||||
|
@ -39,23 +38,11 @@
|
|||
},
|
||||
});
|
||||
|
||||
const itemStore = useItemStore();
|
||||
const reItem = /\/api\/v1\/items\/.*/gm;
|
||||
const rmItemStoreObserver = defineObserver("itemStore", {
|
||||
handler: r => {
|
||||
if (r.status === 201 || r.url.match(reItem)) {
|
||||
itemStore.refresh();
|
||||
}
|
||||
console.debug("itemStore handler called by observer");
|
||||
},
|
||||
});
|
||||
|
||||
const eventBus = useEventBus();
|
||||
eventBus.on(
|
||||
EventTypes.ClearStores,
|
||||
() => {
|
||||
labelStore.refresh();
|
||||
itemStore.refresh();
|
||||
locationStore.refresh();
|
||||
},
|
||||
"stores"
|
||||
|
@ -64,7 +51,6 @@
|
|||
onUnmounted(() => {
|
||||
rmLabelStoreObserver();
|
||||
rmLocationStoreObserver();
|
||||
rmItemStoreObserver();
|
||||
eventBus.off(EventTypes.ClearStores, "stores");
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { BaseAPI, route } from "../base";
|
||||
import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts";
|
||||
import { Group, GroupInvitation, GroupInvitationCreate, GroupStatistics, GroupUpdate } from "../types/data-contracts";
|
||||
|
||||
export class GroupApi extends BaseAPI {
|
||||
createInvitation(data: GroupInvitationCreate) {
|
||||
|
@ -21,4 +21,10 @@ export class GroupApi extends BaseAPI {
|
|||
url: route("/groups"),
|
||||
});
|
||||
}
|
||||
|
||||
statistics() {
|
||||
return this.http.get<GroupStatistics>({
|
||||
url: route("/groups/statistics"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,13 @@ export interface Group {
|
|||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface GroupStatistics {
|
||||
totalItems: number;
|
||||
totalLabels: number;
|
||||
totalLocations: number;
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
export interface GroupUpdate {
|
||||
currency: string;
|
||||
name: string;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useAuthStore } from "~~/stores/auth";
|
||||
import { useItemStore } from "~~/stores/items";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
|
||||
|
@ -16,33 +15,33 @@
|
|||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const itemsStore = useItemStore();
|
||||
const items = computed(() => itemsStore.items);
|
||||
|
||||
const locationStore = useLocationStore();
|
||||
const locations = computed(() => locationStore.locations);
|
||||
|
||||
const labelsStore = useLabelStore();
|
||||
const labels = computed(() => labelsStore.labels);
|
||||
|
||||
const totalItems = computed(() => items.value?.length || 0);
|
||||
const totalLocations = computed(() => locations.value?.length || 0);
|
||||
const totalLabels = computed(() => labels.value?.length || 0);
|
||||
const { data: statistics } = useAsyncData(async () => {
|
||||
const { data } = await api.group.statistics();
|
||||
return data;
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: "Locations",
|
||||
value: totalLocations,
|
||||
},
|
||||
{
|
||||
label: "Items",
|
||||
value: totalItems,
|
||||
},
|
||||
{
|
||||
label: "Labels",
|
||||
value: totalLabels,
|
||||
},
|
||||
];
|
||||
const stats = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: "Locations",
|
||||
value: statistics.value?.totalLocations || 0,
|
||||
},
|
||||
{
|
||||
label: "Items",
|
||||
value: statistics.value?.totalItems || 0,
|
||||
},
|
||||
{
|
||||
label: "Labels",
|
||||
value: statistics.value?.totalLabels || 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const importDialog = ref(false);
|
||||
const importCsv = ref(null);
|
||||
|
@ -141,7 +140,7 @@
|
|||
class="grid grid-cols-1 divide-y divide-base-300 border-t border-base-300 sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
|
||||
>
|
||||
<div v-for="stat in stats" :key="stat.label" class="px-6 py-5 text-center text-sm font-medium">
|
||||
<span class="text-base-900 font-bold">{{ stat.value.value }}</span>
|
||||
<span class="text-base-900 font-bold">{{ stat.value }}</span>
|
||||
{{ " " }}
|
||||
<span class="text-base-600">{{ stat.label }}</span>
|
||||
</div>
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ItemOut } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export const useItemStore = defineStore("items", {
|
||||
state: () => ({
|
||||
allItems: null as ItemOut[] | null,
|
||||
client: useUserApi(),
|
||||
}),
|
||||
getters: {
|
||||
/**
|
||||
* items represents the items that are currently in the store. The store is
|
||||
* synched with the server by intercepting the API calls and updating on the
|
||||
* response.
|
||||
*/
|
||||
items(state): ItemOut[] {
|
||||
if (state.allItems === null) {
|
||||
Promise.resolve(this.refresh());
|
||||
}
|
||||
return state.allItems;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async refresh(): Promise<ItemOut[]> {
|
||||
const result = await this.client.items.getAll();
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
|
||||
this.allItems = result.data.items;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Reference in a new issue