feat: add group statistics on profile page

* resolve file not found error and add constants

* add group stats and storage functionality

* generate new types

* add statistics and storage cap graphs

* fix: add loadFood query param #1103

* refactor to flex view
This commit is contained in:
Hayden 2022-03-27 15:12:18 -08:00 committed by GitHub
parent b57e42a3b3
commit 1e90dc2022
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 326 additions and 32 deletions

View File

@ -1,5 +1,6 @@
import { BaseCRUDAPI } from "../_base"; import { BaseCRUDAPI } from "../_base";
import { GroupInDB, UserOut } from "~/types/api-types/user"; import { GroupInDB, UserOut } from "~/types/api-types/user";
import { GroupStatistics, GroupStorage } from "~/types/api-types/group";
const prefix = "/api"; const prefix = "/api";
@ -11,6 +12,8 @@ const routes = {
permissions: `${prefix}/groups/permissions`, permissions: `${prefix}/groups/permissions`,
preferences: `${prefix}/groups/preferences`, preferences: `${prefix}/groups/preferences`,
statistics: `${prefix}/groups/statistics`,
storage: `${prefix}/groups/storage`,
invitation: `${prefix}/groups/invitations`, invitation: `${prefix}/groups/invitations`,
@ -103,4 +106,12 @@ export class GroupAPI extends BaseCRUDAPI<GroupInDB, CreateGroup> {
// TODO: This should probably be a patch request, which isn't offered by the API currently // TODO: This should probably be a patch request, which isn't offered by the API currently
return await this.requests.put<Permissions, SetPermissions>(routes.permissions, payload); return await this.requests.put<Permissions, SetPermissions>(routes.permissions, payload);
} }
async statistics() {
return await this.requests.get<GroupStatistics>(routes.statistics);
}
async storage() {
return await this.requests.get<GroupStorage>(routes.storage);
}
} }

View File

@ -0,0 +1,53 @@
<template>
<v-card :min-width="minWidth" :to="to" :hover="to ? true : false">
<div class="d-flex flex-no-wrap">
<v-avatar class="ml-3 mr-0 mt-3" color="primary" size="36">
<v-icon color="white" class="pa-1">
{{ activeIcon }}
</v-icon>
</v-avatar>
<div>
<v-card-title class="text-subtitle-1 pt-2 pb-2">
<slot name="title"></slot>
</v-card-title>
<v-card-subtitle class="pb-2">
<slot name="value"></slot>
</v-card-subtitle>
</div>
</div>
</v-card>
</template>
<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
export default defineComponent({
props: {
icon: {
type: String,
default: null,
},
minWidth: {
type: String,
default: "",
},
to: {
type: String,
default: null,
},
},
setup(props) {
const { $globals } = useContext();
const activeIcon = computed(() => {
return props.icon ?? $globals.icons.primary;
});
return {
activeIcon,
};
},
});
</script>
<style scoped></style>

View File

@ -97,7 +97,7 @@ export const useRecipes = (all = false, fetchRecipes = true) => {
})(); })();
async function refreshRecipes() { async function refreshRecipes() {
const { data } = await api.recipes.getAll(start, end); const { data } = await api.recipes.getAll(start, end, { loadFood: true });
if (data) { if (data) {
recipes.value = data; recipes.value = data;
} }

View File

@ -38,6 +38,51 @@
</div> </div>
</v-card> </v-card>
</section> </section>
<section class="my-3">
<div>
<h3 class="headline">Account Summary</h3>
<p>Here's a summary of your group's information</p>
</div>
<v-row tag="section">
<v-col cols="12" sm="12" md="6">
<v-card outlined>
<v-card-title class="headline pb-0"> Group Statistics </v-card-title>
<v-card-text class="py-0">
Your Group Statistics provide some insight how you're using Mealie.
</v-card-text>
<v-card-text class="d-flex flex-wrap justify-center align-center" style="gap: 0.8rem">
<StatsCards
v-for="(value, key) in stats"
:key="`${key}-${value}`"
:min-width="$vuetify.breakpoint.xs ? '100%' : '158'"
:icon="getStatsIcon(key)"
:to="getStatsTo(key)"
>
<template #title> {{ getStatsTitle(key) }}</template>
<template #value> {{ value }}</template>
</StatsCards>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="12" md="6" class="d-flex align-strart">
<v-card outlined>
<v-card-title class="headline pb-0"> Storage Capacity </v-card-title>
<v-card-text class="py-0">
Your storage capacity is a calculation of the images and assets you have uploaded.
<strong> This feature is currently inactive</strong>
</v-card-text>
<v-card-text>
<v-progress-linear :value="storageUsedPercentage" color="accent" class="rounded" height="30">
<template #default>
<strong> {{ storageText }} </strong>
</template>
</v-progress-linear>
</v-card-text>
</v-card>
</v-col>
</v-row>
</section>
<v-divider class="my-7"></v-divider>
<section> <section>
<div> <div>
<h3 class="headline">Personal</h3> <h3 class="headline">Personal</h3>
@ -149,18 +194,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, useContext, ref, toRefs, reactive } from "@nuxtjs/composition-api"; import { computed, defineComponent, useContext, ref, toRefs, reactive, useAsync } from "@nuxtjs/composition-api";
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue"; import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import UserAvatar from "@/components/Domain/User/UserAvatar.vue"; import UserAvatar from "@/components/Domain/User/UserAvatar.vue";
import { useAsyncKey } from "~/composables/use-utils";
import StatsCards from "~/components/global/StatsCards.vue";
export default defineComponent({ export default defineComponent({
name: "UserProfile", name: "UserProfile",
components: { components: {
UserProfileLinkCard, UserProfileLinkCard,
UserAvatar, UserAvatar,
StatsCards,
}, },
scrollToTop: true, scrollToTop: true,
setup() { setup() {
@ -218,7 +266,81 @@ export default defineComponent({
return false; return false;
}); });
const stats = useAsync(async () => {
const { data } = await api.groups.statistics();
if (data) {
return data;
}
}, useAsyncKey());
const statsText: { [key: string]: string } = {
totalRecipes: "Recipes",
totalUsers: "Users",
totalCategories: "Categories",
totalTags: "Tags",
totalTools: "Tools",
};
function getStatsTitle(key: string) {
return statsText[key] ?? "unknown";
}
const { $globals } = useContext();
const iconText: { [key: string]: string } = {
totalUsers: $globals.icons.user,
totalCategories: $globals.icons.tags,
totalTags: $globals.icons.tags,
totalTools: $globals.icons.potSteam,
};
function getStatsIcon(key: string) {
return iconText[key] ?? $globals.icons.primary;
}
const statsTo: { [key: string]: string } = {
totalRecipes: "/recipes/all",
totalUsers: "/group/members",
totalCategories: "/recipes/categories",
totalTags: "/recipes/tags",
totalTools: "/recipes/tools",
};
function getStatsTo(key: string) {
return statsTo[key] ?? "unknown";
}
const storage = useAsync(async () => {
const { data } = await api.groups.storage();
if (data) {
return data;
}
}, useAsyncKey());
const storageUsedPercentage = computed(() => {
if (!storage.value) {
return 0;
}
return (storage.value?.usedStorageBytes / storage.value?.totalStorageBytes) * 100 ?? 0;
});
const storageText = computed(() => {
if (!storage.value) {
return "Loading...";
}
return `${storage.value.usedStorageStr} / ${storage.value.totalStorageStr}`;
});
return { return {
storageText,
storageUsedPercentage,
getStatsTitle,
getStatsIcon,
getStatsTo,
stats,
user, user,
constructLink, constructLink,
generatedLink, generatedLink,

View File

@ -176,6 +176,19 @@ export interface GroupEventNotifierUpdate {
options?: GroupEventNotifierOptions; options?: GroupEventNotifierOptions;
id: string; id: string;
} }
export interface GroupStatistics {
totalRecipes: number;
totalUsers: number;
totalCategories: number;
totalTags: number;
totalTools: number;
}
export interface GroupStorage {
usedStorageBytes: number;
usedStorageStr: string;
totalStorageBytes: number;
totalStorageStr: string;
}
export interface IngredientFood { export interface IngredientFood {
name: string; name: string;
description?: string; description?: string;

View File

@ -10,6 +10,7 @@
import BannerExperimental from "@/components/global/BannerExperimental.vue"; import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue"; import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue"; import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import InputLabelType from "@/components/global/InputLabelType.vue"; import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue"; import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue"; import DevDumpJson from "@/components/global/DevDumpJson.vue";
@ -45,6 +46,7 @@ declare module "vue" {
BannerExperimental: typeof BannerExperimental; BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog; BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor; RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards;
InputLabelType: typeof InputLabelType; InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard; BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson; DevDumpJson: typeof DevDumpJson;

View File

@ -1,6 +1,9 @@
import os import os
from pathlib import Path from pathlib import Path
megabyte = 1_048_576
gigabyte = 1_073_741_824
def pretty_size(size: int) -> str: def pretty_size(size: int) -> str:
""" """
@ -23,7 +26,11 @@ def get_dir_size(path: Path | str) -> int:
""" """
Get the size of a directory Get the size of a directory
""" """
total_size = os.path.getsize(path) try:
total_size = os.path.getsize(path)
except FileNotFoundError:
return 0
for item in os.listdir(path): for item in os.listdir(path):
itempath = os.path.join(path, item) itempath = os.path.join(path, item)
if os.path.isfile(itempath): if os.path.isfile(itempath):

View File

@ -1,32 +1,31 @@
from typing import Union from typing import Union
from sqlalchemy.orm.session import Session from pydantic import UUID4
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.schema.meal_plan.meal import MealPlanOut from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.users import User
from mealie.schema.group.group_statistics import GroupStatistics
from mealie.schema.user.user import GroupInDB from mealie.schema.user.user import GroupInDB
from .repository_generic import RepositoryGeneric from .repository_generic import RepositoryGeneric
class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]): class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]):
def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanOut]:
"""A Helper function to get the group from the database and return a sorted list of
Args:
session (Session): SqlAlchemy Session
match_value (str): Match Value
match_key (str, optional): Match Key. Defaults to "name".
Returns:
list[MealPlanOut]: [description]
"""
group: GroupInDB = session.query(self.sql_model).filter_by(**{match_key: match_value}).one_or_none()
return group.mealplans
def get_by_name(self, name: str, limit=1) -> Union[GroupInDB, Group, None]: def get_by_name(self, name: str, limit=1) -> Union[GroupInDB, Group, None]:
dbgroup = self.session.query(self.sql_model).filter_by(**{"name": name}).one_or_none() dbgroup = self.session.query(self.sql_model).filter_by(**{"name": name}).one_or_none()
if dbgroup is None: if dbgroup is None:
return None return None
return self.schema.from_orm(dbgroup) return self.schema.from_orm(dbgroup)
def statistics(self, group_id: UUID4) -> GroupStatistics:
return GroupStatistics(
total_recipes=self.session.query(RecipeModel).filter_by(group_id=group_id).count(),
total_users=self.session.query(User).filter_by(group_id=group_id).count(),
total_categories=self.session.query(Category).filter_by(group_id=group_id).count(),
total_tags=self.session.query(Tag).filter_by(group_id=group_id).count(),
total_tools=self.session.query(Tool).filter_by(group_id=group_id).count(),
)

View File

@ -2,6 +2,7 @@ from random import randint
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
from pydantic import UUID4
from slugify import slugify from slugify import slugify
from sqlalchemy import and_, func from sqlalchemy import and_, func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -163,7 +164,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.limit(limit) .limit(limit)
] ]
def get_by_slug(self, group_id: UUID, slug: str, limit=1) -> Optional[Recipe]: def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Optional[Recipe]:
dbrecipe = ( dbrecipe = (
self.session.query(RecipeModel) self.session.query(RecipeModel)
.filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug) .filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
@ -172,3 +173,6 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
if dbrecipe is None: if dbrecipe is None:
return None return None
return self.schema.from_orm(dbrecipe) return self.schema.from_orm(dbrecipe)
def all_ids(self, group_id: UUID4) -> list[UUID4]:
return [tpl[0] for tpl in self.session.query(RecipeModel.id).filter(RecipeModel.group_id == group_id).all()]

View File

@ -1,3 +1,5 @@
from functools import cached_property
from fastapi import HTTPException, status from fastapi import HTTPException, status
from mealie.routes._base.abc_controller import BaseUserController from mealie.routes._base.abc_controller import BaseUserController
@ -5,13 +7,29 @@ from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.group.group_permissions import SetPermissions from mealie.schema.group.group_permissions import SetPermissions
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.group.group_statistics import GroupStatistics, GroupStorage
from mealie.schema.user.user import GroupInDB, UserOut from mealie.schema.user.user import GroupInDB, UserOut
from mealie.services.group_services.group_service import GroupService
router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"]) router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@controller(router) @controller(router)
class GroupSelfServiceController(BaseUserController): class GroupSelfServiceController(BaseUserController):
@cached_property
def service(self) -> GroupService:
return GroupService(self.group_id, self.repos)
@router.get("/self", response_model=GroupInDB)
def get_logged_in_user_group(self):
"""Returns the Group Data for the Current User"""
return self.group
@router.get("/members", response_model=list[UserOut])
def get_group_members(self):
"""Returns the Group of user lists"""
return self.repos.users.multi_query(query_by={"group_id": self.group.id}, override_schema=UserOut)
@router.get("/preferences", response_model=ReadGroupPreferences) @router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(self): def get_group_preferences(self):
return self.group.preferences return self.group.preferences
@ -20,18 +38,8 @@ class GroupSelfServiceController(BaseUserController):
def update_group_preferences(self, new_pref: UpdateGroupPreferences): def update_group_preferences(self, new_pref: UpdateGroupPreferences):
return self.repos.group_preferences.update(self.group_id, new_pref) return self.repos.group_preferences.update(self.group_id, new_pref)
@router.get("/self", response_model=GroupInDB)
async def get_logged_in_user_group(self):
"""Returns the Group Data for the Current User"""
return self.group
@router.get("/members", response_model=list[UserOut])
async def get_group_members(self):
"""Returns the Group of user lists"""
return self.repos.users.multi_query(query_by={"group_id": self.group.id}, override_schema=UserOut)
@router.put("/permissions", response_model=UserOut) @router.put("/permissions", response_model=UserOut)
async def set_member_permissions(self, permissions: SetPermissions): def set_member_permissions(self, permissions: SetPermissions):
self.checks.can_manage() self.checks.can_manage()
target_user = self.repos.users.get(permissions.user_id) target_user = self.repos.users.get(permissions.user_id)
@ -47,3 +55,11 @@ class GroupSelfServiceController(BaseUserController):
target_user.can_organize = permissions.can_organize target_user.can_organize = permissions.can_organize
return self.repos.users.update(permissions.user_id, target_user) return self.repos.users.update(permissions.user_id, target_user)
@router.get("/statistics", response_model=GroupStatistics)
def get_statistics(self):
return self.service.calculate_statistics()
@router.get("/storage", response_model=GroupStorage)
def get_storage(self):
return self.service.calculate_group_storage()

View File

@ -6,5 +6,6 @@ from .group_migration import *
from .group_permissions import * from .group_permissions import *
from .group_preferences import * from .group_preferences import *
from .group_shopping_list import * from .group_shopping_list import *
from .group_statistics import *
from .invite_token import * from .invite_token import *
from .webhook import * from .webhook import *

View File

@ -0,0 +1,26 @@
from mealie.pkgs.stats import fs_stats
from mealie.schema._mealie import MealieModel
class GroupStatistics(MealieModel):
total_recipes: int
total_users: int
total_categories: int
total_tags: int
total_tools: int
class GroupStorage(MealieModel):
used_storage_bytes: int
used_storage_str: str
total_storage_bytes: int
total_storage_str: str
@classmethod
def bytes(cls, used_storage_bytes: int, total_storage_bytes: int) -> "GroupStorage":
return cls(
used_storage_bytes=used_storage_bytes,
used_storage_str=fs_stats.pretty_size(used_storage_bytes),
total_storage_bytes=total_storage_bytes,
total_storage_str=fs_stats.pretty_size(total_storage_bytes),
)

View File

@ -0,0 +1,40 @@
from pydantic import UUID4
from mealie.pkgs.stats import fs_stats
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_statistics import GroupStatistics, GroupStorage
from mealie.services._base_service import BaseService
ALLOWED_SIZE = 500 * fs_stats.megabyte
class GroupService(BaseService):
def __init__(self, group_id: UUID4, repos: AllRepositories):
self.group_id = group_id
self.repos = repos
super().__init__()
def calculate_statistics(self, group_id: None | UUID4 = None) -> GroupStatistics:
"""
calculate_statistics calculates the statistics for the group and returns
a GroupStatistics object.
"""
target_id = group_id or self.group_id
return self.repos.groups.statistics(target_id)
def calculate_group_storage(self, group_id: None | UUID4 = None) -> GroupStorage:
"""
calculate_group_storage calculates the storage used by the group and returns
a GroupStorage object.
"""
target_id = group_id or self.group_id
all_ids = self.repos.recipes.all_ids(target_id)
used_size = sum(
fs_stats.get_dir_size(f"{self.directories.RECIPE_DATA_DIR}/{str(recipe_id)}") for recipe_id in all_ids
)
return GroupStorage.bytes(used_size, ALLOWED_SIZE)