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 user_has_doms(sub) and sub.id == current_user.db_user.id:
abort(403)
return
if request.method == "POST": 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_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,54 +189,111 @@ 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 = {
'id': op.id, 'permissions': {
'name': op.name, 'can_view': user_can_orders_pools_view(user, user),
'scheduled': op.scheduled, 'can_details': user_can_orders_pools_details(user, user),
'time': op.time, 'can_edit': user_can_orders_pools_edit(user, user)
'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)
]) 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/<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': {
'id': op.id, 'can_view': user_can_orders_pools_view(current_user.db_user, sub),
'name': op.name, 'can_details': user_can_orders_pools_details(current_user.db_user, sub),
'scheduled': op.scheduled, 'can_edit': user_can_orders_pools_edit(current_user.db_user, sub)
'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)
]) 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/<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,25 +95,26 @@ 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]);
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,116 +144,146 @@ export const OrderSets: React.FC<OrderSetProps> = ({
} }
}} }}
> >
<Grid gutter="md"> {permissions?.can_view ? (
{orderSets <Grid gutter="md">
? orderSets.map( {orderSets
({ ? orderSets.map(
id, ({
name, id,
scheduled, name,
orders, scheduled,
time, orders,
weekdays, time,
weekends, weekdays,
probability, weekends,
punishment_pool_name, probability,
}) => ( punishment_pool_name,
<Grid.Col key={id} span={{ base: 12, sm: 6 }}> }) => (
<Card <Grid.Col key={id} span={{ base: 12, sm: 6 }}>
shadow="sm" <Card
padding="lg" shadow="sm"
radius="md" padding="lg"
withBorder radius="md"
mb="0" withBorder
> mb="0"
<Flex direction="column" gap="md" h="100%"> >
<Title order={4}>{name}</Title> <Flex direction="column" gap="md" h="100%">
{scheduled ? ( <Title order={4}>{name}</Title>
<> {scheduled ? (
<Flex gap="xs"> <>
<Text> <Flex gap="xs">
<b>Scheduled:</b> <Text>
</Text> <b>Scheduled:</b>
{time.split(",").map((time) => ( </Text>
<div key={time}> {time.split(",").map((time) => (
<TimeValue <div key={time}>
key={time} <TimeValue
value={time} key={time}
format="12h" value={time}
/> format="12h"
</div> />
))} </div>
</Flex> ))}
<Flex gap="md" align="center"> </Flex>
{weekdays ? ( <Flex gap="md" align="center">
<Badge color="orange.7">Weekdays</Badge> {weekdays ? (
) : null} <Badge color="orange.7">Weekdays</Badge>
{weekends ? ( ) : null}
<Badge color="orange.7">Weekends</Badge> {weekends ? (
) : null} <Badge color="orange.7">Weekends</Badge>
<RingProgress ) : null}
size={30} <RingProgress
thickness={5} size={30}
label={ thickness={5}
<Text size="xs" ml="lg"> label={
{probability * 100}% <Text size="xs" ml="lg">
</Text> {probability * 100}%
} </Text>
sections={[ }
{ color: "orange", value: probability * 100 }, sections={[
]} {
/> color: "orange",
</Flex> value: probability * 100,
</> },
) : null} ]}
{punishment_pool_name ? ( />
<Text flex={1}> </Flex>
<b>Punishments:</b> {punishment_pool_name} </>
</Text>
) : null}
<Flex justify="end" align="flex-end" gap="sm">
{orders.length > 0 ? (
<DonutChart
flex={1}
size={130}
thickness={30}
data={orders.map(({ name, weight }, idx) => ({
name,
value: weight,
color:
COLORS_ROTATION[idx % COLORS_ROTATION.length],
}))}
tooltipDataSource="segment"
/>
) : null} ) : null}
<ConfirmDialogButton {punishment_pool_name ? (
buttonColor="red.8" <Text flex={1}>
buttonText="Delete" <b>Punishments:</b> {punishment_pool_name}
variant="transparent" </Text>
text={`Are you sure you want to delete ${name}?`} ) : null}
onConfirm={() => handleDelete(id)} <Flex justify="end" align="flex-end" gap="sm">
> {permissions?.can_details && orders.length > 0 ? (
<IconTrash /> <DonutChart
</ConfirmDialogButton> flex={1}
<NavigateButton to={`/orders/${username}/${id}`}> size={130}
<IconPencil style={{ marginRight: "0.5rem" }} /> thickness={30}
Edit data={orders.map(({ name, weight }, idx) => ({
</NavigateButton> name,
value: weight,
color:
COLORS_ROTATION[
idx % COLORS_ROTATION.length
],
}))}
tooltipDataSource="segment"
/>
) : null}
{permissions?.can_edit ? (
<>
<ConfirmDialogButton
buttonColor="red.8"
buttonText="Delete"
variant="transparent"
text={`Are you sure you want to delete ${name}?`}
onConfirm={() => handleDelete(id)}
>
<IconTrash />
</ConfirmDialogButton>
<NavigateButton
to={`/orders/${username}/${id}`}
>
<IconPencil
style={{ marginRight: "0.5rem" }}
/>
Edit
</NavigateButton>
</>
) : null}
</Flex>
</Flex> </Flex>
</Flex> </Card>
</Card> </Grid.Col>
</Grid.Col> ),
), )
) : 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>
<Box my="lg"> {permissions?.can_edit ? (
<NavigateButton to={`/orders/${username}/new`}> <Box my="lg">
<IconPlus style={{ marginRight: "0.5rem" }} /> <NavigateButton to={`/orders/${username}/new`}>
New <IconPlus style={{ marginRight: "0.5rem" }} />
</NavigateButton> New
</Box> </NavigateButton>
</> </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",