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 json
from peewee import JOIN, fn
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,
orders_pool=orders_pool,
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}')
timeline_event_put(
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,
orders_pool=orders_pool,
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))
timeline_event_put(
TIMELINE_ORDER_CONFIRMED,
f'Order confirmed - {order_status.text.split("\n")[0]}',
order_status.text.split("\n")[0],
order_status.user,
order_status.pool,
order_status,
@ -222,7 +222,7 @@ async def order_check(order_status_id):
punishment_status = issue_result['order_status']
timeline_event_put(
TIMELINE_ORDER_PUNISHED,
f'Order punished - {punishment_status.text.split("\n")[0]}',
punishment_status.text.split("\n")[0],
punishment_status.user,
punishment_status.pool,
punishment_status,
@ -233,7 +233,7 @@ async def order_check(order_status_id):
elif 'reason' in issue_result:
timeline_event_put(
TIMELINE_ORDER_NOT_PUNISHED,
f'Order not punished - {issue_result['reason']}',
issue_result['reason'],
order_status.user,
order_status.pool,
order_status

View file

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

View file

@ -13,6 +13,9 @@ def timezone():
def sqlite_time(dt):
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):
order_time_arr = list(map(int, str.split(':')))
return datetime.time(

View file

@ -1,11 +1,13 @@
import re
import requests
import json
from functools import wraps
from flask import Blueprint, jsonify, abort, request
from flask_login import login_required, current_user
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 util import time_sqlite
RE_MASTODON_ATTN_LIST = re.compile(r'^(@(\w+)(@([\w\.]+))? )*(@\w+)(@[\w\.]+)?$')
@ -44,6 +46,31 @@ def profile():
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')
@login_required
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'])
except:
except Exception as e:
print(e)
transaction.rollback()
abort(500)
elif request.method == 'DELETE':

View file

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

View file

@ -1,15 +1,17 @@
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 { IconPencil } from "@tabler/icons-react";
import { NavigateButton } from "./NavigateButton";
import { OrderSetProps, OrderSets } from "./OrderSets";
import { useUserContext } from "./UserContext";
import { TimelineList } from "./TimelineList";
export const subsListLoader = () =>
Promise.all([
fetch("/api/orders/").then((response) => response.json()),
fetch("/api/subs").then((response) => response.json()),
fetch("/api/timeline").then((response) => response.json()),
]);
interface SubsListProps {
@ -58,14 +60,17 @@ const SubsList: React.FC<SubsListProps> = ({ subs }) => (
);
export const Dashboard: React.FC = () => {
const [orderSets, subs] =
useLoaderData<[OrderSetProps["orderSets"], SubsListProps["subs"]]>();
const [orderSets, subs, timeline] =
useLoaderData<
[OrderSetProps["orderSets"], SubsListProps["subs"], TimelineEvent[]]
>();
const { username } = useUserContext();
return (
<>
<OrderSets orderSets={orderSets} username={username} />
{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 {
Params,
useLoaderData,
useParams,
useFetcher,
Link,
} from "react-router";
import { ConfirmDialogButton } from "./ConfirmDialogButton";
import { NavigateButton } from "./NavigateButton";
import { Params, useLoaderData, useParams, Link } from "react-router";
import { OrderSetProps, OrderSets } from "./OrderSets";
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;
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;
}