Add advanced sub permissions

This commit is contained in:
John Groszko 2026-04-23 12:35:12 -05:00
parent 05d5093e23
commit 40486f81fd
9 changed files with 405 additions and 172 deletions

View file

@ -40,6 +40,10 @@ class User(BaseModel):
verify_mastodon_favorite = BooleanField(null=False, default=False) verify_mastodon_favorite = BooleanField(null=False, default=False)
verify_delay = IntegerField(null=True) 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): def mastodon_account(self):
if self.mastodon_server is None or self.mastodon_username is None: if self.mastodon_server is None or self.mastodon_username is None:
return return

View file

@ -54,6 +54,42 @@ def user_preferences_set(id, mastodon_post_public, mastodon_attn_list):
def user_has_doms(id): def user_has_doms(id):
return DomSubUsers.select().where(DomSubUsers.sub_id == id).count() > 0 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): def mastodon_server_get(name):
return MastodonServer.get(name=name) return MastodonServer.get(name=name)

View file

@ -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')

View file

@ -6,7 +6,7 @@ from flask import Blueprint, jsonify, abort, request
from flask_login import login_required, current_user 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.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.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 settings import MASTODON_OAUTH_CLIENT_NAME, MASTODON_OAUTH_REDIRECT_URI, MASTODON_OAUTH_SCOPES, MASTODON_OAUTH_CLIENT_WEBSITE
from util import time_sqlite from util import time_sqlite
@ -163,11 +163,11 @@ def authorized_sub(func):
@login_required @login_required
@authorized_sub @authorized_sub
def sub(username, sub): def sub(username, sub):
if request.method == "POST":
if user_has_doms(sub) and sub.id == current_user.db_user.id: if user_has_doms(sub) and sub.id == current_user.db_user.id:
abort(403) abort(403)
return return
if request.method == "POST":
sub.verify_mastodon_favorite = bool(request.json['verify_mastodon_favorite']) sub.verify_mastodon_favorite = bool(request.json['verify_mastodon_favorite'])
sub.verify_mastodon_alt_text = bool(request.json['verify_mastodon_alt_text']) sub.verify_mastodon_alt_text = bool(request.json['verify_mastodon_alt_text'])
if request.json['verify_delay'] is not None: if request.json['verify_delay'] is not None:
@ -189,7 +189,17 @@ def sub(username, sub):
@api.route('/orders/') @api.route('/orders/')
@login_required @login_required
def my_order_sets(): def my_order_sets():
return jsonify([ 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)
}
}
if result['permissions']['can_details']:
result['pools'] = [
{ {
'id': op.id, 'id': op.id,
'name': op.name, 'name': op.name,
@ -207,13 +217,39 @@ def my_order_sets():
} }
for op for op
in orders_pool_list(current_user.db_user) 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/<username>/sets') @api.route('/orders/<username>/sets')
@login_required @login_required
@authorized_sub @authorized_sub
def sub_order_sets(username, sub): def sub_order_sets(username, sub):
return jsonify([ 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)
}
}
if result['permissions']['can_details']:
result['pools'] = [
{ {
'id': op.id, 'id': op.id,
'name': op.name, 'name': op.name,
@ -231,12 +267,33 @@ def sub_order_sets(username, sub):
} }
for op for op
in orders_pool_list(sub.id) 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/<username>/sets/', methods=['POST']) @api.route('/orders/<username>/sets/', methods=['POST'])
@login_required @login_required
@authorized_sub @authorized_sub
def sub_order_set_create(username, 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 # Create new
with database.atomic() as transaction: with database.atomic() as transaction:
try: try:
@ -287,6 +344,10 @@ def sub_order_set(username, set_id, sub):
op = orders_pool(sub.id, set_id) op = orders_pool(sub.id, set_id)
if request.method == 'POST': 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): def update_add_ons(order, add_ons):
for updated_add_on in add_ons: for updated_add_on in add_ons:
if isinstance(updated_add_on['id'], int): if isinstance(updated_add_on['id'], int):
@ -371,6 +432,10 @@ def sub_order_set(username, set_id, sub):
abort(500) abort(500)
elif request.method == 'DELETE': elif request.method == 'DELETE':
try: try:
if not user_can_orders_pools_edit(current_user.db_user, sub):
abort(403)
return
op.delete_instance(recursive=True) op.delete_instance(recursive=True)
timeline_event_put( timeline_event_put(

View file

@ -3,7 +3,7 @@ import { Text, Title, Flex, Card, Image } from "@mantine/core";
import { useLoaderData } from "react-router"; import { useLoaderData } from "react-router";
import { IconPencil } from "@tabler/icons-react"; import { IconPencil } from "@tabler/icons-react";
import { NavigateButton } from "./NavigateButton"; import { NavigateButton } from "./NavigateButton";
import { OrderSetProps, OrderSets } from "./OrderSets"; import { OrderSets, OrderSetsResponse } from "./OrderSets";
import { useUserContext } from "./UserContext"; import { useUserContext } from "./UserContext";
import { TimelineList } from "./TimelineList"; import { TimelineList } from "./TimelineList";
@ -54,14 +54,20 @@ const SubsList: React.FC<SubsListProps> = ({ subs }) => (
export const Dashboard: React.FC = () => { export const Dashboard: React.FC = () => {
const [orderSets, subs, timeline] = const [orderSets, subs, timeline] =
useLoaderData< useLoaderData<
[OrderSetProps["orderSets"], SubsListProps["subs"], TimelineEvent[]] [OrderSetsResponse, SubsListProps["subs"], TimelineEvent[]]
>(); >();
const { username } = useUserContext(); const { username } = useUserContext();
return ( return (
<> <>
{timeline.length > 0 ? <TimelineList timeline={timeline} /> : null} {timeline.length > 0 ? <TimelineList timeline={timeline} /> : null}
<OrderSets orderSets={orderSets} username={username} /> {orderSets.permissions?.can_view && orderSets.pools.length > 0 ? (
<OrderSets
orderSets={orderSets.pools}
permissions={orderSets.permissions}
username={username}
/>
) : null}
{subs.length > 0 ? <SubsList subs={subs} /> : null} {subs.length > 0 ? <SubsList subs={subs} /> : null}
</> </>
); );

View file

@ -83,9 +83,22 @@ export const OrderSet: React.FC = () => {
const [orderSets, setOrderSets] = React.useState([]); const [orderSets, setOrderSets] = React.useState([]);
React.useEffect(() => { React.useEffect(() => {
fetch(`/api/orders/${username}/sets`) fetch(`/api/orders/${username}/sets`).then(async (response) => {
.then((response) => response.json()) if (response.ok) {
.then(setOrderSets); 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]); }, [username]);
const [showScheduling, setShowScheduling] = React.useState( const [showScheduling, setShowScheduling] = React.useState(

View file

@ -32,6 +32,28 @@ const COLORS_ROTATION = [
"gray.4", "gray.4",
]; ];
export interface OrderSetsResponse {
pools: (Pick<
OrderSet,
| "id"
| "name"
| "scheduled"
| "time"
| "weekends"
| "weekdays"
| "orders"
| "probability"
> & {
orders: Pick<OrderSetOrder, "id" | "name" | "weight">;
punishment_pool_name: string;
})[];
permissions: {
can_view: boolean;
can_details: boolean;
can_edit: boolean;
};
}
export interface OrderSetProps { export interface OrderSetProps {
orderSets: (Pick< orderSets: (Pick<
OrderSet, OrderSet,
@ -49,12 +71,14 @@ export interface OrderSetProps {
})[]; })[];
username: string; username: string;
linkBack?: React.ReactNode; linkBack?: React.ReactNode;
permissions?: OrderSetsResponse["permissions"];
} }
export const OrderSets: React.FC<OrderSetProps> = ({ export const OrderSets: React.FC<OrderSetProps> = ({
orderSets, orderSets,
username, username,
linkBack, linkBack,
permissions,
}) => { }) => {
const { username: current_user } = useUserContext(); const { username: current_user } = useUserContext();
const fetcher = useFetcher(); const fetcher = useFetcher();
@ -71,12 +95,13 @@ export const OrderSets: React.FC<OrderSetProps> = ({
const [isMastodonSet, setIsMastodonSet] = React.useState(true); const [isMastodonSet, setIsMastodonSet] = React.useState(true);
React.useEffect(() => { React.useEffect(() => {
if (username) { if (username) {
fetch(`/api/subs/${username}`) fetch(`/api/subs/${username}`).then(async (response) => {
.then((response) => response.json()) if (response.ok) {
.then((data) => { const data = await response.json();
if (!data.mastodon_server || !data.mastodon_username) { if (!data.mastodon_server || !data.mastodon_username) {
setIsMastodonSet(false); setIsMastodonSet(false);
} }
}
}); });
} }
}, [username]); }, [username]);
@ -84,12 +109,12 @@ export const OrderSets: React.FC<OrderSetProps> = ({
const [portalRef, setPortalRef] = React.useState<HTMLElement | null>(); const [portalRef, setPortalRef] = React.useState<HTMLElement | null>();
return ( return (
<> <Box my="lg">
<Box mb="lg"> <Box mb="lg">
<Title order={1}>Order Sets for {username}</Title> <Title order={1}>Order Sets for {username}</Title>
{linkBack ? linkBack : null} {linkBack ? linkBack : null}
</Box> </Box>
{orderSets.length > 0 && isMastodonSet ? null : ( {isMastodonSet ? null : (
<Flex justify="center"> <Flex justify="center">
<Alert <Alert
variant="light" variant="light"
@ -119,6 +144,7 @@ export const OrderSets: React.FC<OrderSetProps> = ({
} }
}} }}
> >
{permissions?.can_view ? (
<Grid gutter="md"> <Grid gutter="md">
{orderSets {orderSets
? orderSets.map( ? orderSets.map(
@ -175,7 +201,10 @@ export const OrderSets: React.FC<OrderSetProps> = ({
</Text> </Text>
} }
sections={[ sections={[
{ color: "orange", value: probability * 100 }, {
color: "orange",
value: probability * 100,
},
]} ]}
/> />
</Flex> </Flex>
@ -187,7 +216,7 @@ export const OrderSets: React.FC<OrderSetProps> = ({
</Text> </Text>
) : null} ) : null}
<Flex justify="end" align="flex-end" gap="sm"> <Flex justify="end" align="flex-end" gap="sm">
{orders.length > 0 ? ( {permissions?.can_details && orders.length > 0 ? (
<DonutChart <DonutChart
flex={1} flex={1}
size={130} size={130}
@ -196,11 +225,15 @@ export const OrderSets: React.FC<OrderSetProps> = ({
name, name,
value: weight, value: weight,
color: color:
COLORS_ROTATION[idx % COLORS_ROTATION.length], COLORS_ROTATION[
idx % COLORS_ROTATION.length
],
}))} }))}
tooltipDataSource="segment" tooltipDataSource="segment"
/> />
) : null} ) : null}
{permissions?.can_edit ? (
<>
<ConfirmDialogButton <ConfirmDialogButton
buttonColor="red.8" buttonColor="red.8"
buttonText="Delete" buttonText="Delete"
@ -210,10 +243,16 @@ export const OrderSets: React.FC<OrderSetProps> = ({
> >
<IconTrash /> <IconTrash />
</ConfirmDialogButton> </ConfirmDialogButton>
<NavigateButton to={`/orders/${username}/${id}`}> <NavigateButton
<IconPencil style={{ marginRight: "0.5rem" }} /> to={`/orders/${username}/${id}`}
>
<IconPencil
style={{ marginRight: "0.5rem" }}
/>
Edit Edit
</NavigateButton> </NavigateButton>
</>
) : null}
</Flex> </Flex>
</Flex> </Flex>
</Card> </Card>
@ -222,13 +261,29 @@ export const OrderSets: React.FC<OrderSetProps> = ({
) )
: null} : null}
</Grid> </Grid>
) : (
<Flex justify="center">
<Alert
variant="light"
color="orange"
title="Warning"
icon={<IconAlertTriangle />}
my="md"
w="40rem"
>
You are not authorized to view order pools for <b>{username}</b>.
</Alert>
</Flex>
)}
</div> </div>
{permissions?.can_edit ? (
<Box my="lg"> <Box my="lg">
<NavigateButton to={`/orders/${username}/new`}> <NavigateButton to={`/orders/${username}/new`}>
<IconPlus style={{ marginRight: "0.5rem" }} /> <IconPlus style={{ marginRight: "0.5rem" }} />
New New
</NavigateButton> </NavigateButton>
</Box> </Box>
</> ) : null}
</Box>
); );
}; };

View file

@ -1,31 +1,34 @@
import React from "react"; import React from "react";
import { Params, useLoaderData, useParams, Link } from "react-router"; import { Params, useLoaderData, useParams, Link } from "react-router";
import { OrderSetProps, OrderSets } from "./OrderSets"; import { OrderSetProps, OrderSets, OrderSetsResponse } from "./OrderSets";
import { ProfileVerification } from "./ProfileVerification"; import { ProfileVerification } from "./ProfileVerification";
import { Anchor, Title } from "@mantine/core"; import { Anchor, Title } from "@mantine/core";
export const subOrderSetsLoader = async ({ export const userOrderSetsLoader = async ({
params: { username }, params: { username },
}: { }: {
params: Params<string>; params: Params<string>;
}) => fetch(`/api/orders/${username}/sets`).then((response) => response.json()); }) => 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 { username: sub_username } = useParams();
const orderSets = useLoaderData<OrderSetProps["orderSets"]>(); const orderSets = useLoaderData<OrderSetsResponse>();
const [profile, setProfile] = React.useState<UserProfile | null>(null); const [profile, setProfile] = React.useState<UserProfile | null>(null);
React.useEffect(() => { React.useEffect(() => {
fetch(`/api/subs/${sub_username}`) // fetch(`/api/subs/${sub_username}`).then(async (response) => {
.then((response) => response.json()) // if (response.ok) {
.then(setProfile); // setProfile(await response.json());
// }
// });
}, [sub_username]); }, [sub_username]);
return ( return (
<> <>
<OrderSets <OrderSets
username={sub_username} username={sub_username}
orderSets={orderSets} orderSets={orderSets.pools}
permissions={orderSets.permissions}
linkBack={ linkBack={
<Anchor component={Link} to={`/dashboard/`}> <Anchor component={Link} to={`/dashboard/`}>
Return to dashboard Return to dashboard

View file

@ -21,7 +21,7 @@ import "@mantine/notifications/styles.css";
import "@mantine/charts/styles.css"; import "@mantine/charts/styles.css";
import { Dashboard, subsListLoader } from "./Dashboard"; import { Dashboard, subsListLoader } from "./Dashboard";
import { SubOrderSets, subOrderSetsLoader } from "./SubOrderSets"; import { UserOrderSets, userOrderSetsLoader } from "./UserOrderSets";
import { OrderSet, orderSetLoader, orderSetAction } from "./OrderSet"; import { OrderSet, orderSetLoader, orderSetAction } from "./OrderSet";
import { UserContextProvider } from "./UserContext"; import { UserContextProvider } from "./UserContext";
import { Header } from "./Header"; import { Header } from "./Header";
@ -75,8 +75,8 @@ const router = createBrowserRouter([
}, },
{ {
path: "orders/:username", path: "orders/:username",
Component: SubOrderSets, Component: UserOrderSets,
loader: subOrderSetsLoader, loader: userOrderSetsLoader,
}, },
{ {
path: "orders/:username/new", path: "orders/:username/new",