Timeline UI

This commit is contained in:
Johnny Gear 2026-03-08 13:44:59 -05:00
parent 0ba2956562
commit 78ad0b7762
11 changed files with 144 additions and 23 deletions

View file

@ -1,4 +1,5 @@
import datetime import datetime
import json
from peewee import JOIN, fn from peewee import JOIN, fn
from util import sqlite_time from util import sqlite_time
@ -171,5 +172,13 @@ def timeline_event_put(type, text, user, orders_pool, order_status=None, extra=N
user=user, user=user,
orders_pool=orders_pool, orders_pool=orders_pool,
order_status=order_status, order_status=order_status,
extra=extra extra=json.dumps(extra) if extra is not None else None
) )
def timeline_event_recent(user_id):
return (TimelineEvent
.select()
.where(TimelineEvent.user_id == user_id)
.order_by(TimelineEvent.updated_at.desc())
.limit(10)
)

View file

@ -24,7 +24,7 @@ async def do_order_issue(order_pool_id):
logger.info(f'Issued order id {order_status.id}') logger.info(f'Issued order id {order_status.id}')
timeline_event_put( timeline_event_put(
TIMELINE_ORDER_ISSUED, TIMELINE_ORDER_ISSUED,
f"Order issued from {orders_pool.name} - {order_status.text.split("\n")[0]}", order_status.text.split("\n")[0],
user=orders_pool.user, user=orders_pool.user,
orders_pool=orders_pool, orders_pool=orders_pool,
order_status=order_status, order_status=order_status,

View file

@ -199,7 +199,7 @@ async def order_check(order_status_id):
logger.info('Confirmed order %s' % (order_status.id)) logger.info('Confirmed order %s' % (order_status.id))
timeline_event_put( timeline_event_put(
TIMELINE_ORDER_CONFIRMED, TIMELINE_ORDER_CONFIRMED,
f'Order confirmed - {order_status.text.split("\n")[0]}', order_status.text.split("\n")[0],
order_status.user, order_status.user,
order_status.pool, order_status.pool,
order_status, order_status,
@ -222,7 +222,7 @@ async def order_check(order_status_id):
punishment_status = issue_result['order_status'] punishment_status = issue_result['order_status']
timeline_event_put( timeline_event_put(
TIMELINE_ORDER_PUNISHED, TIMELINE_ORDER_PUNISHED,
f'Order punished - {punishment_status.text.split("\n")[0]}', punishment_status.text.split("\n")[0],
punishment_status.user, punishment_status.user,
punishment_status.pool, punishment_status.pool,
punishment_status, punishment_status,
@ -233,7 +233,7 @@ async def order_check(order_status_id):
elif 'reason' in issue_result: elif 'reason' in issue_result:
timeline_event_put( timeline_event_put(
TIMELINE_ORDER_NOT_PUNISHED, TIMELINE_ORDER_NOT_PUNISHED,
f'Order not punished - {issue_result['reason']}', issue_result['reason'],
order_status.user, order_status.user,
order_status.pool, order_status.pool,
order_status order_status

View file

@ -99,7 +99,7 @@ class OrderScheduler():
) )
timeline_event_put( timeline_event_put(
TIMELINE_ORDER_ISSUED, TIMELINE_ORDER_ISSUED,
f"Order issued - {order_status.text.split("\n")[0]}", order_status.text.split("\n")[0],
user=orders_pool.user, user=orders_pool.user,
orders_pool=orders_pool, orders_pool=orders_pool,
order_status=order_status, order_status=order_status,

View file

@ -13,6 +13,9 @@ def timezone():
def sqlite_time(dt): def sqlite_time(dt):
return dt.astimezone(datetime.UTC).strftime('%Y-%m-%d %H:%M:%S') return dt.astimezone(datetime.UTC).strftime('%Y-%m-%d %H:%M:%S')
def time_sqlite(dt):
return dt.replace(tzinfo=datetime.UTC)
def order_time(str): def order_time(str):
order_time_arr = list(map(int, str.split(':'))) order_time_arr = list(map(int, str.split(':')))
return datetime.time( return datetime.time(

View file

@ -1,11 +1,13 @@
import re import re
import requests import requests
import json
from functools import wraps from functools import wraps
from flask import Blueprint, jsonify, abort, request 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.models import database, OrdersPool, Order, OrderAddOn, MastodonServer from db.models import database, OrdersPool, Order, OrderAddOn, MastodonServer
from db.queries import user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, user_mastodon_server_set, user_preferences_set from db.queries import timeline_event_recent, user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, 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
RE_MASTODON_ATTN_LIST = re.compile(r'^(@(\w+)(@([\w\.]+))? )*(@\w+)(@[\w\.]+)?$') RE_MASTODON_ATTN_LIST = re.compile(r'^(@(\w+)(@([\w\.]+))? )*(@\w+)(@[\w\.]+)?$')
@ -44,6 +46,31 @@ def profile():
return ('', 204) return ('', 204)
def maybe_json(str):
try:
return json.loads(str)
except:
return None
@api.route('/timeline')
@login_required
def timeline():
return jsonify([
{
"id": t.id,
"updated_at": time_sqlite(t.updated_at).isoformat(),
"type": t.type,
"text": t.text,
"extra": maybe_json(t.extra),
"username": t.user.telegram_username,
"orders_pool": {
"id": t.orders_pool.id,
"name": t.orders_pool.name,
},
"order_status": t.order_status.text if t.order_status is not None else None,
} for t in timeline_event_recent(current_user.db_user.id)
])
@api.route('/mastodon_oauth') @api.route('/mastodon_oauth')
@login_required @login_required
def mastodon_oauth(): def mastodon_oauth():
@ -287,7 +314,8 @@ def sub_order_set(username, set_id, sub):
) )
update_add_ons(order_to_update, updated_order['add_ons']) update_add_ons(order_to_update, updated_order['add_ons'])
except: except Exception as e:
print(e)
transaction.rollback() transaction.rollback()
abort(500) abort(500)
elif request.method == 'DELETE': elif request.method == 'DELETE':

View file

@ -20,6 +20,7 @@
"@mantine/form": "^8.3.13", "@mantine/form": "^8.3.13",
"@mantine/notifications": "^8.3.13", "@mantine/notifications": "^8.3.13",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"moment": "^2.30.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",

View file

@ -1,15 +1,17 @@
import React from "react"; import React from "react";
import { Container, Text, Title, Flex, Card, Image, Box } from "@mantine/core"; 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 { OrderSetProps, OrderSets } from "./OrderSets";
import { useUserContext } from "./UserContext"; import { useUserContext } from "./UserContext";
import { TimelineList } from "./TimelineList";
export const subsListLoader = () => export const subsListLoader = () =>
Promise.all([ Promise.all([
fetch("/api/orders/").then((response) => response.json()), fetch("/api/orders/").then((response) => response.json()),
fetch("/api/subs").then((response) => response.json()), fetch("/api/subs").then((response) => response.json()),
fetch("/api/timeline").then((response) => response.json()),
]); ]);
interface SubsListProps { interface SubsListProps {
@ -58,14 +60,17 @@ const SubsList: React.FC<SubsListProps> = ({ subs }) => (
); );
export const Dashboard: React.FC = () => { export const Dashboard: React.FC = () => {
const [orderSets, subs] = const [orderSets, subs, timeline] =
useLoaderData<[OrderSetProps["orderSets"], SubsListProps["subs"]]>(); useLoaderData<
[OrderSetProps["orderSets"], SubsListProps["subs"], TimelineEvent[]]
>();
const { username } = useUserContext(); const { username } = useUserContext();
return ( return (
<> <>
<OrderSets orderSets={orderSets} username={username} /> <OrderSets orderSets={orderSets} username={username} />
{subs.length > 0 ? <SubsList subs={subs} /> : null} {subs.length > 0 ? <SubsList subs={subs} /> : null}
{timeline.length > 0 ? <TimelineList timeline={timeline} /> : null}
</> </>
); );
}; };

View file

@ -1,16 +1,5 @@
import { Container, Title, Card, Text, Box, Flex, Badge } from "@mantine/core";
import { TimeValue } from "@mantine/dates";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { import { Params, useLoaderData, useParams, Link } from "react-router";
Params,
useLoaderData,
useParams,
useFetcher,
Link,
} from "react-router";
import { ConfirmDialogButton } from "./ConfirmDialogButton";
import { NavigateButton } from "./NavigateButton";
import { OrderSetProps, OrderSets } from "./OrderSets"; import { OrderSetProps, OrderSets } from "./OrderSets";
export const subOrderSetsLoader = async ({ export const subOrderSetsLoader = async ({

View file

@ -0,0 +1,75 @@
import { Timeline, Text, Title, Box, Flex } from "@mantine/core";
import React from "react";
import moment from "moment";
import { Link } from "react-router";
import {
IconAlertCircle,
IconArrowBadgeRightFilled,
IconCheck,
IconExternalLink,
IconX,
} from "@tabler/icons-react";
const TIMELINE_TYPE = {
ORDER_NOT_ISSUED: {
title: "Order not issued",
color: "gray.6",
bullet: <IconX />,
},
ORDER_ISSUED: {
title: "Order issued",
color: "yellow.6",
bullet: <IconArrowBadgeRightFilled />,
},
ORDER_CONFIRMED: {
title: "Order confirmed",
color: "green.6",
bullet: <IconCheck />,
},
ORDER_NOT_PUNISHED: {
title: "Order not punished",
color: "gray.6",
bullet: null,
},
ORDER_PUNISHED: {
title: "Order punished",
color: "red.6",
bullet: <IconAlertCircle />,
},
};
export const TimelineList: React.FC<{
timeline: TimelineEvent[];
}> = ({ timeline }) => (
<Box>
<Title order={1} mb="lg">
Timeline
</Title>
<Timeline bulletSize={24} lineWidth={2}>
{timeline.map(
({ id, updated_at, type, text, username, orders_pool, extra }) => (
<Timeline.Item key={id} active {...(TIMELINE_TYPE[type] ?? {})}>
<Text size="sm">{text}</Text>
<Flex mt={4} gap="xs">
{extra?.mastodon_status_url ? (
<Text size="xs">
<a href={extra.mastodon_status_url} target="_blank">
Mastodon Post <IconExternalLink size="0.8rem" />
</a>
</Text>
) : null}
{orders_pool ? (
<Text size="xs">
<Link to={`/orders/${username}/${orders_pool.id}`}>
{orders_pool.name}
</Link>
</Text>
) : null}
<Text size="xs">{moment(updated_at).fromNow()}</Text>
</Flex>
</Timeline.Item>
),
)}
</Timeline>
</Box>
);

View file

@ -24,3 +24,14 @@ type OrderSet = {
confirm_delay: string; confirm_delay: string;
punishment_pool_id?: number; punishment_pool_id?: number;
} }
type TimelineEvent = {
id: number;
updated_at: string;
type: string;
text: string;
extra: Record<string, string>;
username: string;
orders_pool: Pick<OrderSet, "id" | "name">;
order_status: string;
}