From 40486f81fdd75a8b74fe150510dc6e825303ef30 Mon Sep 17 00:00:00 2001 From: John Groszko Date: Thu, 23 Apr 2026 12:35:12 -0500 Subject: [PATCH] Add advanced sub permissions --- db/models.py | 4 + db/queries.py | 36 +++ migrations/029_add_sub_permissions.py | 51 ++++ web/api.py | 147 ++++++--- web/vite/src/Dashboard.tsx | 12 +- web/vite/src/OrderSet.tsx | 19 +- web/vite/src/OrderSets.tsx | 283 +++++++++++------- .../{SubOrderSets.tsx => UserOrderSets.tsx} | 19 +- web/vite/src/main.tsx | 6 +- 9 files changed, 405 insertions(+), 172 deletions(-) create mode 100644 migrations/029_add_sub_permissions.py rename web/vite/src/{SubOrderSets.tsx => UserOrderSets.tsx} (71%) diff --git a/db/models.py b/db/models.py index 847c4d5..bd4fa14 100644 --- a/db/models.py +++ b/db/models.py @@ -40,6 +40,10 @@ class User(BaseModel): verify_mastodon_favorite = BooleanField(null=False, default=False) verify_delay = IntegerField(null=True) + permission_orders_pools_view = BooleanField(null=False, default=True) + permission_orders_pools_details = BooleanField(null=False, default=True) + permission_orders_pools_edit = BooleanField(null=False, default=True) + def mastodon_account(self): if self.mastodon_server is None or self.mastodon_username is None: return diff --git a/db/queries.py b/db/queries.py index 5fa7f4f..659645c 100644 --- a/db/queries.py +++ b/db/queries.py @@ -54,6 +54,42 @@ def user_preferences_set(id, mastodon_post_public, mastodon_attn_list): def user_has_doms(id): return DomSubUsers.select().where(DomSubUsers.sub_id == id).count() > 0 +def user_doms(id): + return [d.dom for d in DomSubUsers.select(DomSubUsers.dom).where(DomSubUsers.sub_id == id)] + +def user_can_orders_pools_view(user, sub): + doms = user_doms(sub.id) + + if len(doms) > 0: + if user == sub: + return user.permission_orders_pools_view + else: + return user in doms + else: + return user.id == sub.id + +def user_can_orders_pools_details(user, sub): + doms = user_doms(sub.id) + + if len(doms) > 0: + if user == sub: + return user.permission_orders_pools_details + else: + return user in doms + else: + return user.id == sub.id + +def user_can_orders_pools_edit(user, sub): + doms = user_doms(sub.id) + + if len(doms) > 0: + if user == sub: + return user.permission_orders_pools_edit + else: + return user in doms + else: + return user.id == sub.id + def mastodon_server_get(name): return MastodonServer.get(name=name) diff --git a/migrations/029_add_sub_permissions.py b/migrations/029_add_sub_permissions.py new file mode 100644 index 0000000..5ba687a --- /dev/null +++ b/migrations/029_add_sub_permissions.py @@ -0,0 +1,51 @@ +"""Peewee migrations -- 029_add_sub_permissions.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + migrator.add_fields( + 'user', + + permission_orders_pools_view=pw.BooleanField(default=True), + permission_orders_pools_details=pw.BooleanField(default=True), + permission_orders_pools_edit=pw.BooleanField(default=True)) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_fields('user', 'permission_orders_pools_view', 'permission_orders_pools_details', 'permission_orders_pools_edit') diff --git a/web/api.py b/web/api.py index 06eb2b3..3977d13 100644 --- a/web/api.py +++ b/web/api.py @@ -6,7 +6,7 @@ from flask import Blueprint, jsonify, abort, request from flask_login import login_required, current_user from db.constants import TIMELINE_ORDERS_POOL_CREATED, TIMELINE_ORDERS_POOL_DELETED, TIMELINE_ORDERS_POOL_UPDATED from db.models import database, OrdersPool, Order, OrderAddOn, MastodonServer -from db.queries import timeline_event_put, timeline_event_recent, user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, user_has_doms, user_mastodon_server_set, user_preferences_set +from db.queries import timeline_event_put, timeline_event_recent, user_can_orders_pools_details, user_can_orders_pools_edit, user_can_orders_pools_view, user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, user_has_doms, user_mastodon_server_set, user_preferences_set from settings import MASTODON_OAUTH_CLIENT_NAME, MASTODON_OAUTH_REDIRECT_URI, MASTODON_OAUTH_SCOPES, MASTODON_OAUTH_CLIENT_WEBSITE from util import time_sqlite @@ -163,11 +163,11 @@ def authorized_sub(func): @login_required @authorized_sub def sub(username, sub): - if user_has_doms(sub) and sub.id == current_user.db_user.id: - abort(403) - return - if request.method == "POST": + if user_has_doms(sub) and sub.id == current_user.db_user.id: + abort(403) + return + sub.verify_mastodon_favorite = bool(request.json['verify_mastodon_favorite']) sub.verify_mastodon_alt_text = bool(request.json['verify_mastodon_alt_text']) if request.json['verify_delay'] is not None: @@ -189,54 +189,111 @@ def sub(username, sub): @api.route('/orders/') @login_required def my_order_sets(): - return jsonify([ - { - 'id': op.id, - 'name': op.name, - 'scheduled': op.scheduled, - 'time': op.time, - 'weekends': op.weekends, - 'weekdays': op.weekdays, - 'probability': op.probability, - 'punishment_pool_name': op.punishment_pool.name if op.punishment_pool is not None else None, - 'orders': [{ - 'id': order.id, - 'name': order.name, - 'weight': order.weight - } for order in op.orders] + user = current_user.db_user + result = { + 'permissions': { + 'can_view': user_can_orders_pools_view(user, user), + 'can_details': user_can_orders_pools_details(user, user), + 'can_edit': user_can_orders_pools_edit(user, user) } - for op - in orders_pool_list(current_user.db_user) - ]) + } + + if result['permissions']['can_details']: + result['pools'] = [ + { + 'id': op.id, + 'name': op.name, + 'scheduled': op.scheduled, + 'time': op.time, + 'weekends': op.weekends, + 'weekdays': op.weekdays, + 'probability': op.probability, + 'punishment_pool_name': op.punishment_pool.name if op.punishment_pool is not None else None, + 'orders': [{ + 'id': order.id, + 'name': order.name, + 'weight': order.weight + } for order in op.orders] + } + for op + in orders_pool_list(current_user.db_user) + ] + elif result['permissions']['can_view']: + result['pools'] = [ + { + 'id': op.id, + 'name': op.name, + 'scheduled': op.scheduled, + 'time': op.time, + 'weekends': op.weekends, + 'weekdays': op.weekdays, + 'probability': op.probability, + 'punishment_pool_name': op.punishment_pool.name if op.punishment_pool is not None else None, + } + for op + in orders_pool_list(current_user.db_user) + ] + + return jsonify(result) @api.route('/orders//sets') @login_required @authorized_sub def sub_order_sets(username, sub): - return jsonify([ - { - 'id': op.id, - 'name': op.name, - 'scheduled': op.scheduled, - 'time': op.time, - 'weekends': op.weekends, - 'weekdays': op.weekdays, - 'probability': op.probability, - 'punishment_pool_name': op.punishment_pool.name if op.punishment_pool is not None else None, - 'orders': [{ - 'id': order.id, - 'name': order.name, - 'weight': order.weight - } for order in op.orders] + result = { + 'permissions': { + 'can_view': user_can_orders_pools_view(current_user.db_user, sub), + 'can_details': user_can_orders_pools_details(current_user.db_user, sub), + 'can_edit': user_can_orders_pools_edit(current_user.db_user, sub) } - for op - in orders_pool_list(sub.id) - ]) + } + + if result['permissions']['can_details']: + result['pools'] = [ + { + 'id': op.id, + 'name': op.name, + 'scheduled': op.scheduled, + 'time': op.time, + 'weekends': op.weekends, + 'weekdays': op.weekdays, + 'probability': op.probability, + 'punishment_pool_name': op.punishment_pool.name if op.punishment_pool is not None else None, + 'orders': [{ + 'id': order.id, + 'name': order.name, + 'weight': order.weight + } for order in op.orders] + } + for op + in orders_pool_list(sub.id) + ] + elif result['permissions']['can_view']: + result['pools'] = [ + { + 'id': op.id, + 'name': op.name, + 'scheduled': op.scheduled, + 'time': op.time, + 'weekends': op.weekends, + 'weekdays': op.weekdays, + 'probability': op.probability, + 'punishment_pool_name': op.punishment_pool.name if op.punishment_pool is not None else None, + } + for op + in orders_pool_list(sub.id) + ] + + return jsonify(result) @api.route('/orders//sets/', methods=['POST']) @login_required @authorized_sub def sub_order_set_create(username, sub): + if not user_can_orders_pools_edit(current_user.db_user, sub): + abort(403) + return + # Create new with database.atomic() as transaction: try: @@ -287,6 +344,10 @@ def sub_order_set(username, set_id, sub): op = orders_pool(sub.id, set_id) if request.method == 'POST': + if not user_can_orders_pools_edit(current_user.db_user, sub): + abort(403) + return + def update_add_ons(order, add_ons): for updated_add_on in add_ons: if isinstance(updated_add_on['id'], int): @@ -371,6 +432,10 @@ def sub_order_set(username, set_id, sub): abort(500) elif request.method == 'DELETE': try: + if not user_can_orders_pools_edit(current_user.db_user, sub): + abort(403) + return + op.delete_instance(recursive=True) timeline_event_put( diff --git a/web/vite/src/Dashboard.tsx b/web/vite/src/Dashboard.tsx index efa6b1b..b05e79f 100644 --- a/web/vite/src/Dashboard.tsx +++ b/web/vite/src/Dashboard.tsx @@ -3,7 +3,7 @@ import { Text, Title, Flex, Card, Image } from "@mantine/core"; import { useLoaderData } from "react-router"; import { IconPencil } from "@tabler/icons-react"; import { NavigateButton } from "./NavigateButton"; -import { OrderSetProps, OrderSets } from "./OrderSets"; +import { OrderSets, OrderSetsResponse } from "./OrderSets"; import { useUserContext } from "./UserContext"; import { TimelineList } from "./TimelineList"; @@ -54,14 +54,20 @@ const SubsList: React.FC = ({ subs }) => ( export const Dashboard: React.FC = () => { const [orderSets, subs, timeline] = useLoaderData< - [OrderSetProps["orderSets"], SubsListProps["subs"], TimelineEvent[]] + [OrderSetsResponse, SubsListProps["subs"], TimelineEvent[]] >(); const { username } = useUserContext(); return ( <> {timeline.length > 0 ? : null} - + {orderSets.permissions?.can_view && orderSets.pools.length > 0 ? ( + + ) : null} {subs.length > 0 ? : null} ); diff --git a/web/vite/src/OrderSet.tsx b/web/vite/src/OrderSet.tsx index 1c0cf0b..d9effb1 100644 --- a/web/vite/src/OrderSet.tsx +++ b/web/vite/src/OrderSet.tsx @@ -83,9 +83,22 @@ export const OrderSet: React.FC = () => { const [orderSets, setOrderSets] = React.useState([]); React.useEffect(() => { - fetch(`/api/orders/${username}/sets`) - .then((response) => response.json()) - .then(setOrderSets); + fetch(`/api/orders/${username}/sets`).then(async (response) => { + if (response.ok) { + const data = await response.json(); + + if (!data.permissions?.can_edit) { + notifications.show({ + title: "Error", + message: "Not authorized", + color: "red", + }); + navigate(`/dashboard/`); + } + + setOrderSets(data.pools); + } + }); }, [username]); const [showScheduling, setShowScheduling] = React.useState( diff --git a/web/vite/src/OrderSets.tsx b/web/vite/src/OrderSets.tsx index b9e0361..1444384 100644 --- a/web/vite/src/OrderSets.tsx +++ b/web/vite/src/OrderSets.tsx @@ -32,6 +32,28 @@ const COLORS_ROTATION = [ "gray.4", ]; +export interface OrderSetsResponse { + pools: (Pick< + OrderSet, + | "id" + | "name" + | "scheduled" + | "time" + | "weekends" + | "weekdays" + | "orders" + | "probability" + > & { + orders: Pick; + punishment_pool_name: string; + })[]; + permissions: { + can_view: boolean; + can_details: boolean; + can_edit: boolean; + }; +} + export interface OrderSetProps { orderSets: (Pick< OrderSet, @@ -49,12 +71,14 @@ export interface OrderSetProps { })[]; username: string; linkBack?: React.ReactNode; + permissions?: OrderSetsResponse["permissions"]; } export const OrderSets: React.FC = ({ orderSets, username, linkBack, + permissions, }) => { const { username: current_user } = useUserContext(); const fetcher = useFetcher(); @@ -71,25 +95,26 @@ export const OrderSets: React.FC = ({ const [isMastodonSet, setIsMastodonSet] = React.useState(true); React.useEffect(() => { if (username) { - fetch(`/api/subs/${username}`) - .then((response) => response.json()) - .then((data) => { + fetch(`/api/subs/${username}`).then(async (response) => { + if (response.ok) { + const data = await response.json(); if (!data.mastodon_server || !data.mastodon_username) { setIsMastodonSet(false); } - }); + } + }); } }, [username]); const [portalRef, setPortalRef] = React.useState(); return ( - <> + Order Sets for {username} {linkBack ? linkBack : null} - {orderSets.length > 0 && isMastodonSet ? null : ( + {isMastodonSet ? null : ( = ({ } }} > - - {orderSets - ? orderSets.map( - ({ - id, - name, - scheduled, - orders, - time, - weekdays, - weekends, - probability, - punishment_pool_name, - }) => ( - - - - {name} - {scheduled ? ( - <> - - - Scheduled: - - {time.split(",").map((time) => ( -
- -
- ))} -
- - {weekdays ? ( - Weekdays - ) : null} - {weekends ? ( - Weekends - ) : null} - - {probability * 100}% - - } - sections={[ - { color: "orange", value: probability * 100 }, - ]} - /> - - - ) : null} - {punishment_pool_name ? ( - - Punishments: {punishment_pool_name} - - ) : null} - - {orders.length > 0 ? ( - ({ - name, - value: weight, - color: - COLORS_ROTATION[idx % COLORS_ROTATION.length], - }))} - tooltipDataSource="segment" - /> + {permissions?.can_view ? ( + + {orderSets + ? orderSets.map( + ({ + id, + name, + scheduled, + orders, + time, + weekdays, + weekends, + probability, + punishment_pool_name, + }) => ( + + + + {name} + {scheduled ? ( + <> + + + Scheduled: + + {time.split(",").map((time) => ( +
+ +
+ ))} +
+ + {weekdays ? ( + Weekdays + ) : null} + {weekends ? ( + Weekends + ) : null} + + {probability * 100}% + + } + sections={[ + { + color: "orange", + value: probability * 100, + }, + ]} + /> + + ) : null} - handleDelete(id)} - > - - - - - Edit - + {punishment_pool_name ? ( + + Punishments: {punishment_pool_name} + + ) : null} + + {permissions?.can_details && orders.length > 0 ? ( + ({ + name, + value: weight, + color: + COLORS_ROTATION[ + idx % COLORS_ROTATION.length + ], + }))} + tooltipDataSource="segment" + /> + ) : null} + {permissions?.can_edit ? ( + <> + handleDelete(id)} + > + + + + + Edit + + + ) : null} +
-
-
-
- ), - ) - : null} -
+ + + ), + ) + : null} + + ) : ( + + } + my="md" + w="40rem" + > + You are not authorized to view order pools for {username}. + + + )} - - - - New - - - + {permissions?.can_edit ? ( + + + + New + + + ) : null} +
); }; diff --git a/web/vite/src/SubOrderSets.tsx b/web/vite/src/UserOrderSets.tsx similarity index 71% rename from web/vite/src/SubOrderSets.tsx rename to web/vite/src/UserOrderSets.tsx index 74890f3..1ebbd6e 100644 --- a/web/vite/src/SubOrderSets.tsx +++ b/web/vite/src/UserOrderSets.tsx @@ -1,31 +1,34 @@ import React from "react"; import { Params, useLoaderData, useParams, Link } from "react-router"; -import { OrderSetProps, OrderSets } from "./OrderSets"; +import { OrderSetProps, OrderSets, OrderSetsResponse } from "./OrderSets"; import { ProfileVerification } from "./ProfileVerification"; import { Anchor, Title } from "@mantine/core"; -export const subOrderSetsLoader = async ({ +export const userOrderSetsLoader = async ({ params: { username }, }: { params: Params; }) => fetch(`/api/orders/${username}/sets`).then((response) => response.json()); -export const SubOrderSets: React.FC = () => { +export const UserOrderSets: React.FC = () => { const { username: sub_username } = useParams(); - const orderSets = useLoaderData(); + const orderSets = useLoaderData(); const [profile, setProfile] = React.useState(null); React.useEffect(() => { - fetch(`/api/subs/${sub_username}`) - .then((response) => response.json()) - .then(setProfile); + // fetch(`/api/subs/${sub_username}`).then(async (response) => { + // if (response.ok) { + // setProfile(await response.json()); + // } + // }); }, [sub_username]); return ( <> Return to dashboard diff --git a/web/vite/src/main.tsx b/web/vite/src/main.tsx index 67372b0..3d1255c 100644 --- a/web/vite/src/main.tsx +++ b/web/vite/src/main.tsx @@ -21,7 +21,7 @@ import "@mantine/notifications/styles.css"; import "@mantine/charts/styles.css"; import { Dashboard, subsListLoader } from "./Dashboard"; -import { SubOrderSets, subOrderSetsLoader } from "./SubOrderSets"; +import { UserOrderSets, userOrderSetsLoader } from "./UserOrderSets"; import { OrderSet, orderSetLoader, orderSetAction } from "./OrderSet"; import { UserContextProvider } from "./UserContext"; import { Header } from "./Header"; @@ -75,8 +75,8 @@ const router = createBrowserRouter([ }, { path: "orders/:username", - Component: SubOrderSets, - loader: subOrderSetsLoader, + Component: UserOrderSets, + loader: userOrderSetsLoader, }, { path: "orders/:username/new",