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_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

View file

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

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 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 request.method == "POST":
if user_has_doms(sub) and sub.id == current_user.db_user.id:
abort(403)
return
if request.method == "POST":
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,7 +189,17 @@ def sub(username, sub):
@api.route('/orders/')
@login_required
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,
'name': op.name,
@ -207,13 +217,39 @@ def my_order_sets():
}
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')
@login_required
@authorized_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,
'name': op.name,
@ -231,12 +267,33 @@ def sub_order_sets(username, sub):
}
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'])
@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(

View file

@ -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<SubsListProps> = ({ 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 ? <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}
</>
);

View file

@ -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(

View file

@ -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<OrderSetOrder, "id" | "name" | "weight">;
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<OrderSetProps> = ({
orderSets,
username,
linkBack,
permissions,
}) => {
const { username: current_user } = useUserContext();
const fetcher = useFetcher();
@ -71,12 +95,13 @@ export const OrderSets: React.FC<OrderSetProps> = ({
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]);
@ -84,12 +109,12 @@ export const OrderSets: React.FC<OrderSetProps> = ({
const [portalRef, setPortalRef] = React.useState<HTMLElement | null>();
return (
<>
<Box my="lg">
<Box mb="lg">
<Title order={1}>Order Sets for {username}</Title>
{linkBack ? linkBack : null}
</Box>
{orderSets.length > 0 && isMastodonSet ? null : (
{isMastodonSet ? null : (
<Flex justify="center">
<Alert
variant="light"
@ -119,6 +144,7 @@ export const OrderSets: React.FC<OrderSetProps> = ({
}
}}
>
{permissions?.can_view ? (
<Grid gutter="md">
{orderSets
? orderSets.map(
@ -175,7 +201,10 @@ export const OrderSets: React.FC<OrderSetProps> = ({
</Text>
}
sections={[
{ color: "orange", value: probability * 100 },
{
color: "orange",
value: probability * 100,
},
]}
/>
</Flex>
@ -187,7 +216,7 @@ export const OrderSets: React.FC<OrderSetProps> = ({
</Text>
) : null}
<Flex justify="end" align="flex-end" gap="sm">
{orders.length > 0 ? (
{permissions?.can_details && orders.length > 0 ? (
<DonutChart
flex={1}
size={130}
@ -196,11 +225,15 @@ export const OrderSets: React.FC<OrderSetProps> = ({
name,
value: weight,
color:
COLORS_ROTATION[idx % COLORS_ROTATION.length],
COLORS_ROTATION[
idx % COLORS_ROTATION.length
],
}))}
tooltipDataSource="segment"
/>
) : null}
{permissions?.can_edit ? (
<>
<ConfirmDialogButton
buttonColor="red.8"
buttonText="Delete"
@ -210,10 +243,16 @@ export const OrderSets: React.FC<OrderSetProps> = ({
>
<IconTrash />
</ConfirmDialogButton>
<NavigateButton to={`/orders/${username}/${id}`}>
<IconPencil style={{ marginRight: "0.5rem" }} />
<NavigateButton
to={`/orders/${username}/${id}`}
>
<IconPencil
style={{ marginRight: "0.5rem" }}
/>
Edit
</NavigateButton>
</>
) : null}
</Flex>
</Flex>
</Card>
@ -222,13 +261,29 @@ export const OrderSets: React.FC<OrderSetProps> = ({
)
: null}
</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>
{permissions?.can_edit ? (
<Box my="lg">
<NavigateButton to={`/orders/${username}/new`}>
<IconPlus style={{ marginRight: "0.5rem" }} />
New
</NavigateButton>
</Box>
</>
) : null}
</Box>
);
};

View file

@ -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<string>;
}) => 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<OrderSetProps["orderSets"]>();
const orderSets = useLoaderData<OrderSetsResponse>();
const [profile, setProfile] = React.useState<UserProfile | null>(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 (
<>
<OrderSets
username={sub_username}
orderSets={orderSets}
orderSets={orderSets.pools}
permissions={orderSets.permissions}
linkBack={
<Anchor component={Link} to={`/dashboard/`}>
Return to dashboard

View file

@ -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",