Compare commits

...

10 commits

Author SHA1 Message Date
John Groszko
2089ee4161 Timeline - Link to subs 2026-04-23 16:53:27 -05:00
John Groszko
cfccd424cb Timeline Permissions 2026-04-23 16:46:08 -05:00
John Groszko
34e8d0eae3 UI for advanced sub permissions 2026-04-23 16:46:08 -05:00
John Groszko
40486f81fd Add advanced sub permissions 2026-04-23 16:46:08 -05:00
John Groszko
05d5093e23 Use jinja templates
Modify 2 files
2026-04-23 11:58:16 -05:00
John Groszko
556c1dd7eb Add punish cli command 2026-04-23 11:55:58 -05:00
John Groszko
6a7e996406 Refactor logging
Update 4 files
2026-04-13 21:55:31 -05:00
John Groszko
fa366534f8 More color tweaks 2026-04-07 21:09:00 -05:00
John Groszko
4c5c8bec04 Dark theme 2026-04-07 20:52:59 -05:00
01589eedeb Fix nav issue 2026-03-30 16:51:09 -05:00
30 changed files with 1058 additions and 442 deletions

View file

@ -6,3 +6,9 @@ TIMELINE_ORDER_PUNISHED = "ORDER_PUNISHED"
TIMELINE_ORDERS_POOL_CREATED = "ORDERS_POOL_CREATED" TIMELINE_ORDERS_POOL_CREATED = "ORDERS_POOL_CREATED"
TIMELINE_ORDERS_POOL_UPDATED = "ORDERS_POOL_UPDATED" TIMELINE_ORDERS_POOL_UPDATED = "ORDERS_POOL_UPDATED"
TIMELINE_ORDERS_POOL_DELETED = "ORDERS_POOL_DELETED" TIMELINE_ORDERS_POOL_DELETED = "ORDERS_POOL_DELETED"
TIMELINE_ORDERS_POOL_EVENTS = [
TIMELINE_ORDERS_POOL_CREATED,
TIMELINE_ORDERS_POOL_UPDATED,
TIMELINE_ORDERS_POOL_DELETED
]

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
@ -183,6 +187,9 @@ class OrderStatus(BaseModel):
null=True null=True
) )
def __str__(self):
return f"{self.pool} {self.id}"
class Meta: class Meta:
table_name = 'order_status' table_name = 'order_status'

View file

@ -2,6 +2,7 @@ import datetime
import json import json
from peewee import JOIN, fn from peewee import JOIN, fn
from db.constants import TIMELINE_ORDERS_POOL_EVENTS
from util import sqlite_time from util import sqlite_time
from .models import database, User, OrdersPool, DomSubUsers, Repeat, SkipDay, OrderStatus, MastodonServer, TimelineEvent from .models import database, User, OrdersPool, DomSubUsers, Repeat, SkipDay, OrderStatus, MastodonServer, TimelineEvent
@ -54,6 +55,45 @@ 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_subs(id):
return [d.sub for d in DomSubUsers.select(DomSubUsers.sub).where(DomSubUsers.dom_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)
@ -186,13 +226,22 @@ def timeline_event_put(type, text, user, orders_pool=None, order_status=None, ac
extra=json.dumps(extra) if extra is not None else None extra=json.dumps(extra) if extra is not None else None
) )
def timeline_event_recent(user_ids, actor_ids=None): def timeline_event_recent(user_id, limit=5):
return (TimelineEvent user = User.get_by_id(user_id)
.select()
.where(( can_view_orders_pools = user_can_orders_pools_view(user, user)
TimelineEvent.user_id.in_(user_ids) |
(TimelineEvent.actor_user_id.in_(actor_ids) if actor_ids is not None else True) result = TimelineEvent.select()
))
.order_by(TimelineEvent.updated_at.desc()) if(can_view_orders_pools):
.limit(10) result = result.where(
) (TimelineEvent.user_id == user_id) |
(TimelineEvent.actor_user_id == user_id)
)
else:
result = result.where(
((TimelineEvent.user_id == user_id) & TimelineEvent.type.not_in(TIMELINE_ORDERS_POOL_EVENTS)) |
(TimelineEvent.actor_user_id == user_id)
)
return result.order_by(TimelineEvent.updated_at.desc()).limit(limit)

40
main.py
View file

@ -7,38 +7,24 @@ import json
from db.constants import TIMELINE_ORDER_ISSUED, TIMELINE_ORDER_NOT_ISSUED from db.constants import TIMELINE_ORDER_ISSUED, TIMELINE_ORDER_NOT_ISSUED
from scheduling import OrderScheduler from scheduling import OrderScheduler
from generate import generate_order from generate import generate_order
from orders import order_issue, order_check from orders import order_issue, order_check, punishment_issue
from db.queries import orders_pool_by_id, timeline_event_put from db.queries import orders_pool_by_id, order_status_by_id
from telegram.telegram import handle_commands from telegram.telegram import handle_commands
from telegram.commands import commands from telegram.commands import commands
from util import make_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def do_order_issue(orders_pool_id): async def do_order_issue(orders_pool_id):
orders_pool = orders_pool_by_id(orders_pool_id) orders_pool = orders_pool_by_id(orders_pool_id)
issue_result = await order_issue(orders_pool) await order_issue(orders_pool)
if 'order_status' in issue_result: async def do_punishment_issue(order_status_id):
order_status = issue_result['order_status'] order_status = order_status_by_id(order_status_id)
logger.info(f'Issued order id {order_status.id}')
timeline_event_put( async with make_session() as session:
TIMELINE_ORDER_ISSUED, await punishment_issue(session, order_status)
order_status.text.split("\n")[0],
user=orders_pool.user,
orders_pool=orders_pool,
order_status=order_status,
extra={
"mastodon_status_url": issue_result['mastodon_status']['url']
}
)
elif 'reason' in issue_result:
timeline_event_put(
TIMELINE_ORDER_NOT_ISSUED,
issue_result['reason'],
user=orders_pool.user,
orders_pool=orders_pool
)
if __name__=='__main__': if __name__=='__main__':
logging.basicConfig( logging.basicConfig(
@ -61,6 +47,9 @@ if __name__=='__main__':
parser_issue = subparsers.add_parser('check', help='Check on the status of an order') parser_issue = subparsers.add_parser('check', help='Check on the status of an order')
parser_issue.add_argument('order_status_id') parser_issue.add_argument('order_status_id')
parser_punish = subparsers.add_parser('punish', help="Punish an order")
parser_punish.add_argument('order_status_id')
args = parser.parse_args() args = parser.parse_args()
if args.command == 'generate': if args.command == 'generate':
@ -77,6 +66,11 @@ if __name__=='__main__':
loop.run_until_complete( loop.run_until_complete(
order_check(args.order_status_id) order_check(args.order_status_id)
) )
elif args.command == 'punish':
loop = asyncio.new_event_loop()
loop.run_until_complete(
do_punishment_issue(args.order_status_id)
)
else: else:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
s = OrderScheduler(loop) s = OrderScheduler(loop)

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

160
orders.py
View file

@ -2,7 +2,7 @@ import logging
import datetime import datetime
import asyncio import asyncio
from db.constants import TIMELINE_ORDER_CONFIRMED, TIMELINE_ORDER_NOT_PUNISHED, TIMELINE_ORDER_PUNISHED from db.constants import TIMELINE_ORDER_CONFIRMED, TIMELINE_ORDER_ISSUED, TIMELINE_ORDER_NOT_ISSUED, TIMELINE_ORDER_NOT_PUNISHED, TIMELINE_ORDER_PUNISHED
from util import make_session from util import make_session
from generate import generate_order, generate_punishment from generate import generate_order, generate_punishment
from db.queries import domsubusers_doms, order_status_by_id, order_status_put, order_status_confirm, timeline_event_put from db.queries import domsubusers_doms, order_status_by_id, order_status_put, order_status_confirm, timeline_event_put
@ -10,57 +10,55 @@ from mastodon import Mastodon
from telegram.telegram import Telegram from telegram.telegram import Telegram
from settings import ENV from settings import ENV
from util import timezone from util import timezone
from jinja2 import Environment, PackageLoader, select_autoescape
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
template_env = Environment(
loader=PackageLoader("orders"),
autoescape=select_autoescape()
)
template_env.globals['ENV'] = ENV
def filter_short_time(value):
return value.strftime("%I:%M %p")
template_env.filters["short_time"] = filter_short_time
async def order_mastodon_post(session, orders_pool, orders_str, repeats, due_at, verify_at=None): async def order_mastodon_post(session, orders_pool, orders_str, repeats, due_at, verify_at=None):
user = orders_pool.user user = orders_pool.user
post = "Here are today's orders for @%s -\n\n" % user.mastodon_account() template = template_env.get_template('mastodon_order')
post += orders_str + "\n\n" post = template.render(
if repeats > 1: user=user,
post += f"These are the same orders from the last {repeats} days\n\n" orders_str=orders_str,
post += "Proof of compliance is due by " + due_at.strftime("%I:%M %p") + "\n" repeats=repeats,
if verify_at is not None: due_at=due_at,
post += "Verification due by " + verify_at.strftime("%I:%M %p") + "\n" verify_at=verify_at
post += "\n" )
if ENV == 'dev':
post += "⚠️ DEV"
elif user.mastodon_attn_list:
post += f"ATTN - {user.mastodon_attn_list}\n"
m = Mastodon(session) m = Mastodon(session)
return await m.statusPost(post, user) return await m.statusPost(post, user)
async def order_telegram_post(session, orders_pool, orders_str, repeats, due_at, m_url, verify_at=None): async def order_telegram_post(session, orders_pool, orders_str, repeats, due_at, m_url, verify_at=None):
post = "Here are your orders -\n\n" template = template_env.get_template("telegram_order")
post += orders_str + "\n\n"
if repeats > 1: post = template.render(
post += f"These are the same orders from the last {repeats} days\n\n" orders_str = orders_str,
post += "Proof of compliance is due by " + due_at.strftime("%I:%M %p") + "\n" repeats=repeats,
if verify_at is not None: due_at=due_at,
post += "Verification due by " + verify_at.strftime("%I:%M %p") + "\n" verify_at=verify_at,
post += "\n" m_url=m_url
post += m_url )
if ENV == 'dev':
post += "\n⚠️ DEV"
t = Telegram(session) t = Telegram(session)
await t.message_send(orders_pool.user.telegram_chat_id, post) await t.message_send(orders_pool.user.telegram_chat_id, post)
async def order_telegram_post_need_mastodon(session, orders_pool): async def order_telegram_message(session, orders_pool, message):
post = "Cannot issue an order without a mastodon username" template = template_env.get_template("telegram_message")
if ENV == 'dev':
post += "\n⚠️ DEV"
t = Telegram(session) post = template.render(
await t.message_send(orders_pool.user.telegram_chat_id, post) message=message
)
async def order_telegram_post_none(session, orders_pool):
post = "No orders for today"
if ENV == 'dev':
post += "\n⚠️ DEV"
t = Telegram(session) t = Telegram(session)
await t.message_send(orders_pool.user.telegram_chat_id, post) await t.message_send(orders_pool.user.telegram_chat_id, post)
@ -70,15 +68,33 @@ async def order_issue(orders_pool):
user = orders_pool.user user = orders_pool.user
if user.mastodon_username is None: if user.mastodon_username is None:
logger.info('Cannot issue order without mastodon username') logger.info(f"{orders_pool} - Cannot issue order without mastodon username")
await order_telegram_post_need_mastodon(session, orders_pool) await order_telegram_message(
session, orders_pool,
"Cannot issue order without mastodon username"
)
timeline_event_put(
TIMELINE_ORDER_NOT_ISSUED,
"Cannot issue order without mastodon username",
user=orders_pool.user,
orders_pool=orders_pool
)
return { "reason": "Cannot issue order without mastodon username" } return { "reason": "Cannot issue order without mastodon username" }
orders_info = generate_order(orders_pool) orders_info = generate_order(orders_pool)
if 'orders' not in orders_info: if 'orders' not in orders_info:
logger.info(f"{orders_pool} - {orders_info['reason']}") logger.info(f"{orders_pool} - {orders_info['reason']}")
await order_telegram_post_none(session, orders_pool) await order_telegram_message(
session, orders_pool,
"No orders this time"
)
timeline_event_put(
TIMELINE_ORDER_NOT_ISSUED,
orders_info['reason'],
user=orders_pool.user,
orders_pool=orders_pool
)
return { "reason": orders_info['reason'] } return { "reason": orders_info['reason'] }
orders_str = "\n".join(orders_info['orders']) orders_str = "\n".join(orders_info['orders'])
@ -113,29 +129,42 @@ async def order_issue(orders_pool):
verify_at=verify_at verify_at=verify_at
) )
order_status = order_status_put(
orders_pool,
orders_pool.user,
m_status['id'],
created_at,
due_at,
orders_str,
verify_at=verify_at
)
timeline_event_put(
TIMELINE_ORDER_ISSUED,
order_status.text.split("\n")[0],
user=orders_pool.user,
orders_pool=orders_pool,
order_status=order_status,
extra={
"mastodon_status_url": m_status['url']
}
)
logger.info(f"Issued {order_status}")
return { return {
"order_status" : order_status_put( "order_status" : order_status,
orders_pool,
orders_pool.user,
m_status['id'],
created_at,
due_at,
orders_str,
verify_at=verify_at
),
"mastodon_status": m_status "mastodon_status": m_status
} }
async def punishment_mastodon_post(session, orders_pool, punishment_str, reply_id=None): async def punishment_mastodon_post(session, orders_pool, punishment_str, reply_id=None):
user = orders_pool.user user = orders_pool.user
post = "@%s has failed to post proof of compliance. Here is the punishment -\n\n" % user.mastodon_account() template = template_env.get_template('mastodon_punishment')
post += punishment_str + "\n\n" post = template.render(
user=user,
if ENV == 'dev': punishment_str=punishment_str
post += "⚠️ DEV" )
elif user.mastodon_attn_list:
post += f"ATTN - {user.mastodon_attn_list}\n"
m = Mastodon(session) m = Mastodon(session)
return await m.statusPost( return await m.statusPost(
@ -145,18 +174,19 @@ async def punishment_mastodon_post(session, orders_pool, punishment_str, reply_i
) )
async def punishment_telegram_post(session, orders_pool, punishment_str, m_url): async def punishment_telegram_post(session, orders_pool, punishment_str, m_url):
post = "You failed to show proof of compliance. Here is your punishment -\n\n" template = template_env.get_template('telegram_punishment')
post += punishment_str + "\n\n"
post += m_url post = template.render(
if ENV == 'dev': punishment_str=punishment_str,
post += "\n\n⚠️ DEV" m_url=m_url
)
t = Telegram(session) t = Telegram(session)
await t.message_send(orders_pool.user.telegram_chat_id, post) await t.message_send(orders_pool.user.telegram_chat_id, post)
async def punishment_issue(session, order_status): async def punishment_issue(session, order_status):
if order_status.pool is None or order_status.pool.punishment_pool is None: if order_status.pool is None or order_status.pool.punishment_pool is None:
logger.info(f'Unable to issue a punishment for {order_status.id}, no punishment pool for order pool {order_status.pool.name}') logger.info(f'Unable to issue a punishment for {order_status}, no punishment pool for order pool {order_status.pool.name}')
return { "reason": "No punishment pool"} return { "reason": "No punishment pool"}
punishment_pool = order_status.pool.punishment_pool punishment_pool = order_status.pool.punishment_pool
@ -217,7 +247,7 @@ async def order_check(order_status_id):
user = order_status.user user = order_status.user
if order_status.punishment.count() > 0: if order_status.punishment.count() > 0:
logger.info(f'Punishment already issued for {order_status.id}') logger.info(f'Punishment already issued for {order_status}')
return return
m = Mastodon(session) m = Mastodon(session)
@ -267,7 +297,7 @@ async def order_check(order_status_id):
confirmed_at = d['created_at'] confirmed_at = d['created_at']
order_status_confirm(order_status.id, confirmed_at) order_status_confirm(order_status.id, confirmed_at)
logger.info('Confirmed order %s' % (order_status.id)) logger.info(f"Confirmed order {order_status}")
timeline_event_put( timeline_event_put(
TIMELINE_ORDER_CONFIRMED, TIMELINE_ORDER_CONFIRMED,
order_status.text.split("\n")[0], order_status.text.split("\n")[0],
@ -281,7 +311,7 @@ async def order_check(order_status_id):
break break
if confirmed_at is None: if confirmed_at is None:
logger.info('Order %s remains unconfirmed' % (order_status.id)) logger.info(f"Order {order_status} remains unconfirmed")
due_at = datetime.datetime.fromisoformat(order_status.due_at) due_at = datetime.datetime.fromisoformat(order_status.due_at)
if(due_at < datetime.datetime.now(datetime.UTC)): if(due_at < datetime.datetime.now(datetime.UTC)):
@ -297,7 +327,7 @@ async def order_check(order_status_id):
elif user.verify_mastodon_favorite and had_favorites is False: elif user.verify_mastodon_favorite and had_favorites is False:
reason = "No replies had a favorite from a dom or tagged account" reason = "No replies had a favorite from a dom or tagged account"
logger.info('Time to issue a punishment for %s' % order_status.id) logger.info(f"Time to issue a punishment for {order_status}")
issue_result = await punishment_issue(session, order_status) issue_result = await punishment_issue(session, order_status)

View file

@ -92,12 +92,11 @@ class OrderScheduler():
) )
return return
logger.info(f'Issuing order for {orders_pool.name}[{orders_pool.user.telegram_username}]')
issue_result = await order_issue(orders_pool) issue_result = await order_issue(orders_pool)
if 'order_status' in issue_result: if 'order_status' in issue_result:
order_status = issue_result['order_status'] order_status = issue_result['order_status']
if order_status.due_at is not None or order_status.verify_at is not None: if order_status.due_at is not None or order_status.verify_at is not None:
# Schedule check # Schedule check
check_time = order_status.verify_at if order_status.verify_at is not None else order_status.due_at check_time = order_status.verify_at if order_status.verify_at is not None else order_status.due_at
@ -106,23 +105,6 @@ class OrderScheduler():
self.scheduled_check, self.scheduled_check,
args=(order_status.id,) args=(order_status.id,)
) )
timeline_event_put(
TIMELINE_ORDER_ISSUED,
order_status.text.split("\n")[0],
user=orders_pool.user,
orders_pool=orders_pool,
order_status=order_status,
extra={
"mastodon_status_url": issue_result['mastodon_status']['url']
}
)
elif 'reason' in issue_result:
timeline_event_put(
TIMELINE_ORDER_NOT_ISSUED,
issue_result['reason'],
user=orders_pool.user,
orders_pool=orders_pool
)
async def scheduled_check(self, outstanding_order_id): async def scheduled_check(self, outstanding_order_id):
await order_check(outstanding_order_id) await order_check(outstanding_order_id)

9
templates/mastodon_base Normal file
View file

@ -0,0 +1,9 @@
{% block main %}{% endblock %}
{% block attn %}
{% if user.mastodon_attn_list %}
ATTN - {{user.mastodon_attn_list}}
{% endif %}
{% endblock %}
{% block env %}
{% if ENV == 'dev' %}⚠️ DEV{% endif %}
{% endblock %}

16
templates/mastodon_order Normal file
View file

@ -0,0 +1,16 @@
{% extends "mastodon_base" %}
{% block main %}
Here are today's orders for @{{user.mastodon_account()}}
{{orders_str}}
{% if repeats > 1 %}
These are the same orders from the last {{repeats}} days.
{% endif %}
Proof of compliance is due by {{due_at|short_time}}
{% if verify_at %}
Verification due by {{verify_at|short_time}}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "mastodon_base" %}
{% block main %}
@{{user.mastodon_account()}} failed to show proof of compliance. Here is the punishment -
{{punishment_str}}
{% endblock %}

2
templates/telegram_base Normal file
View file

@ -0,0 +1,2 @@
{% block main %}{% endblock %}
{% if ENV == 'dev' %}⚠️ DEV{% endif %}

View file

@ -0,0 +1,4 @@
{% extends "telegram_base" %}
{% block main %}
{{message}}
{% endblock %}

15
templates/telegram_order Normal file
View file

@ -0,0 +1,15 @@
{% extends "telegram_base" %}
{% block main %}
Here are your orders -
{{orders_str}}
{% if repeats > 1 %}
These are the same orders from the last {{repeats}} days.
{% endif %}
Proof of compliance is due by {{due_at|short_time}}
{% if verify_at %}
Proof of verification is due by {{verify_at|short_time}}
{% endif %}
{{m_url}}
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends "telegram_base" %}
{% block main %}
You failed to show proof of compliance. Here is your punishment -
{{punishment_str}}
{{m_url}}
{% endblock %}

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_doms, 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
@ -79,13 +79,14 @@ def timeline():
"username": t.user.telegram_username, "username": t.user.telegram_username,
"actor_username": t.actor.telegram_username if t.actor is not None else None, "actor_username": t.actor.telegram_username if t.actor is not None else None,
"orders_pool": { "orders_pool": {
"id": t.orders_pool.id, "id": t.orders_pool.id if user_can_orders_pools_edit(current_user.db_user, t.user) else None,
"name": t.orders_pool.name, "name": t.orders_pool.name,
} if t.orders_pool is not None else None, } if (
t.orders_pool is not None and
user_can_orders_pools_view(current_user.db_user, t.user)
) else None,
"order_status": t.order_status.text if t.order_status is not None else None, "order_status": t.order_status.text if t.order_status is not None else None,
} for t in timeline_event_recent( } for t in timeline_event_recent(current_user.db_user.id)
subs,
[current_user.db_user.id,])
]) ])
@api.route('/mastodon_oauth') @api.route('/mastodon_oauth')
@ -163,16 +164,28 @@ 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":
sub.verify_mastodon_favorite = bool(request.json['verify_mastodon_favorite']) if (user_has_doms(sub) and sub.id == current_user.db_user.id and
sub.verify_mastodon_alt_text = bool(request.json['verify_mastodon_alt_text']) ('permission_orders_pools_view' in request.json or
if request.json['verify_delay'] is not None: 'permission_orders_pools_details' in request.json or
'permission_orders_pools_edit' in request.json)):
abort(403)
return
if 'verify_mastodon_favorite' in request.json:
sub.verify_mastodon_favorite = bool(request.json['verify_mastodon_favorite'])
if 'verify_mastodon_alt_text' in request.json:
sub.verify_mastodon_alt_text = bool(request.json['verify_mastodon_alt_text'])
if 'verify_delay' in request.json and request.json['verify_delay'] is not None:
sub.verify_delay = int(request.json["verify_delay"]) sub.verify_delay = int(request.json["verify_delay"])
if 'permission_orders_pools_view' in request.json:
sub.permission_orders_pools_view = bool(request.json['permission_orders_pools_view'])
if 'permission_orders_pools_details' in request.json:
sub.permission_orders_pools_details = bool(request.json['permission_orders_pools_details'])
if 'permission_orders_pools_edit' in request.json:
sub.permission_orders_pools_edit = bool(request.json['permission_orders_pools_edit'])
sub.save() sub.save()
return ('', 204) return ('', 204)
@ -183,60 +196,121 @@ def sub(username, sub):
"mastodon_username": sub.mastodon_username, "mastodon_username": sub.mastodon_username,
"verify_mastodon_alt_text": sub.verify_mastodon_alt_text, "verify_mastodon_alt_text": sub.verify_mastodon_alt_text,
"verify_mastodon_favorite": sub.verify_mastodon_favorite, "verify_mastodon_favorite": sub.verify_mastodon_favorite,
"verify_delay": sub.verify_delay "verify_delay": sub.verify_delay,
"can_edit_permissions": current_user.db_user in user_doms(sub.id),
"permission_orders_pools_view": sub.permission_orders_pools_view,
"permission_orders_pools_details": sub.permission_orders_pools_details,
"permission_orders_pools_edit": sub.permission_orders_pools_edit
}) })
@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 +361,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 +449,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

@ -81,12 +81,11 @@ def index():
print('Redirect to dashboard') print('Redirect to dashboard')
return redirect('/dashboard/') return redirect('/dashboard/')
@app.route('/profile/', defaults={'path': ''}) @app.route('/dashboard/')
@app.route('/dashboard/', defaults={'path': ''}) @app.route('/profile/')
@app.route('/orders/<path:path>') @app.route('/orders/<path:path>')
@app.route('/dashboard/<path:path>')
@login_required @login_required
def dashboard(path): def dashboard(path=''):
return render_template('dashboard.html') return render_template('dashboard.html')
@app.route('/logout') @app.route('/logout')

View file

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { Button, Modal, Text, Flex } from "@mantine/core"; import { Button, Modal, Text, Flex, ButtonVariant } from "@mantine/core";
export const ConfirmDialogButton: React.FC<{ export const ConfirmDialogButton: React.FC<{
text: string; text: string;
@ -9,7 +9,15 @@ export const ConfirmDialogButton: React.FC<{
buttonColor: string; buttonColor: string;
onConfirm: () => void; onConfirm: () => void;
children: React.ReactNode; children: React.ReactNode;
}> = ({ text, buttonText, buttonColor, onConfirm, children }) => { variant?: ButtonVariant;
}> = ({
text,
buttonText,
buttonColor,
onConfirm,
children,
variant = "filled",
}) => {
const [opened, { open, close }] = useDisclosure(); const [opened, { open, close }] = useDisclosure();
const handleConfirm = React.useCallback(() => { const handleConfirm = React.useCallback(() => {
@ -22,13 +30,15 @@ export const ConfirmDialogButton: React.FC<{
<Modal opened={opened} onClose={close} p="sm" withCloseButton={false}> <Modal opened={opened} onClose={close} p="sm" withCloseButton={false}>
<Text mb="xl">{text}</Text> <Text mb="xl">{text}</Text>
<Flex gap="md" justify="flex-end"> <Flex gap="md" justify="flex-end">
<Button onClick={close}>Cancel</Button> <Button variant="outline" onClick={close}>
Cancel
</Button>
<Button color={buttonColor} onClick={handleConfirm}> <Button color={buttonColor} onClick={handleConfirm}>
{buttonText} {buttonText}
</Button> </Button>
</Flex> </Flex>
</Modal> </Modal>
<Button color={buttonColor} onClick={open}> <Button variant={variant} color={buttonColor} onClick={open}>
{children} {children}
</Button> </Button>
</> </>

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";
@ -25,15 +25,7 @@ const SubsList: React.FC<SubsListProps> = ({ subs }) => (
</Title> </Title>
<Flex gap="md" wrap="wrap"> <Flex gap="md" wrap="wrap">
{subs.map(({ sub_username, telegram_photo_url }) => ( {subs.map(({ sub_username, telegram_photo_url }) => (
<Card <Card key={sub_username} padding="lg" withBorder w="320px">
key={sub_username}
shadow="sm"
padding="lg"
radius="md"
withBorder
bg="gray.2"
w="320px"
>
{telegram_photo_url ? ( {telegram_photo_url ? (
<Card.Section> <Card.Section>
<Image <Image
@ -62,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

@ -1,25 +1,32 @@
import { Avatar, Text, Container, Flex, UnstyledButton } from "@mantine/core"; import {
Avatar,
Text,
Container,
Flex,
UnstyledButton,
Anchor,
} from "@mantine/core";
import React from "react"; import React from "react";
import { useUserContext } from "./UserContext"; import { useUserContext } from "./UserContext";
import { Outlet, useNavigate } from "react-router"; import { Link, Outlet, useNavigate } from "react-router";
export const Header: React.FC = () => { export const Header: React.FC = () => {
const { username, telegram_photo_url } = useUserContext(); const { username, telegram_photo_url } = useUserContext();
const navigate = useNavigate(); const navigate = useNavigate();
const handleClick = React.useCallback(() => { const handleClick = React.useCallback(() => {
navigate("/profile"); navigate("/profile/");
}, []); }, []);
return ( return (
<Container p="sm"> <Container p="sm">
<Flex justify="flex-end"> <Flex justify="flex-end">
<UnstyledButton onClick={handleClick}> <Anchor component={Link} to={"/profile/"}>
<Flex align="center" gap="sm"> <Flex align="center" gap="sm">
<Text c="blue">{username}</Text> <Text>{username}</Text>
<Avatar src={telegram_photo_url} /> <Avatar src={telegram_photo_url} />
</Flex> </Flex>
</UnstyledButton> </Anchor>
</Flex> </Flex>
<Outlet /> <Outlet />
</Container> </Container>

View file

@ -16,6 +16,7 @@ import {
NumberInput, NumberInput,
Text, Text,
Select, Select,
Anchor,
} from "@mantine/core"; } from "@mantine/core";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { TimeInput } from "@mantine/dates"; import { TimeInput } from "@mantine/dates";
@ -82,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(
@ -248,7 +262,9 @@ export const OrderSet: React.FC = () => {
<> <>
<Box mb="lg"> <Box mb="lg">
<Title order={1}>{orderSet?.name || "New Order Set"}</Title> <Title order={1}>{orderSet?.name || "New Order Set"}</Title>
<Link to={`/orders/${username}`}>Return to {username}</Link> <Anchor component={Link} to={`/orders/${username}`}>
Return to {username}
</Anchor>
</Box> </Box>
<form id="order-set" onSubmit={handleSubmit}> <form id="order-set" onSubmit={handleSubmit}>
<TextInput {...form.getInputProps("name")} label="Name" /> <TextInput {...form.getInputProps("name")} label="Name" />
@ -259,7 +275,7 @@ export const OrderSet: React.FC = () => {
mt="lg" mt="lg"
/> />
<Collapse in={showScheduling}> <Collapse in={showScheduling}>
<Paper bg="gray.2"> <Paper>
<Input.Wrapper <Input.Wrapper
error={form.getInputProps("probability").error} error={form.getInputProps("probability").error}
label="Probability" label="Probability"
@ -316,7 +332,7 @@ export const OrderSet: React.FC = () => {
</Title> </Title>
{form.getValues().orders.map(({ id: order_id, _delete }, idx) => {form.getValues().orders.map(({ id: order_id, _delete }, idx) =>
_delete ? null : ( _delete ? null : (
<Paper key={order_id} bg="gray.2"> <Paper key={order_id}>
<Flex gap="xl" justify="space-between"> <Flex gap="xl" justify="space-between">
<TextInput <TextInput
{...form.getInputProps(`orders.${idx}.name`)} {...form.getInputProps(`orders.${idx}.name`)}
@ -406,7 +422,7 @@ export const OrderSet: React.FC = () => {
</Button> </Button>
<Box my="40px" /> <Box my="40px" />
<Affix position={{ bottom: 0 }} w="100%"> <Affix position={{ bottom: 0 }} w="100%">
<Paper mb={0} bg="gray.1"> <Paper mb={0}>
<Container> <Container>
<Flex justify="flex-end"> <Flex justify="flex-end">
<Button type="submit" form="order-set"> <Button type="submit" form="order-set">

View file

@ -9,6 +9,7 @@ import {
RingProgress, RingProgress,
Alert, Alert,
Grid, Grid,
Anchor,
} from "@mantine/core"; } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { TimeValue } from "@mantine/dates"; import { TimeValue } from "@mantine/dates";
@ -21,20 +22,38 @@ import { useUserContext } from "./UserContext";
import { DonutChart } from "@mantine/charts"; import { DonutChart } from "@mantine/charts";
const COLORS_ROTATION = [ const COLORS_ROTATION = [
"teal", "orange.8",
"pink", "gray.7",
"lime", "orange.7",
"violet", "gray.6",
"orange", "orange.6",
"blue", "gray.5",
"yellow", "orange.5",
"grape", "gray.4",
"green",
"red",
"cyan",
"gray",
]; ];
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,
@ -52,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();
@ -74,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"
@ -107,7 +129,9 @@ export const OrderSets: React.FC<OrderSetProps> = ({
{username === current_user ? ( {username === current_user ? (
<> <>
<br /> <br />
<Link to="/profile/">Edit Profile</Link> <Anchor component={Link} to="/profile/">
Edit Profile
</Anchor>
</> </>
) : null} ) : null}
</Alert> </Alert>
@ -120,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"
bg="gray.2" withBorder
mb="0" mb="0"
> >
<Flex direction="column" gap="md" h="100%"> <Flex direction="column" gap="md" h="100%">
<Title order={4}>{name}</Title> <Title order={4}>{name}</Title>
{scheduled ? ( {scheduled ? (
<> <>
<Flex gap="xs"> <Flex gap="xs">
<Text> <Text>
<b>Scheduled:</b> <b>Scheduled:</b>
</Text> </Text>
{time.split(",").map((time) => ( {time.split(",").map((time) => (
<div key={time}> <div key={time}>
<TimeValue <TimeValue
key={time} key={time}
value={time} value={time}
format="12h" format="12h"
/> />
</div> </div>
))} ))}
</Flex> </Flex>
<Flex gap="md" align="center"> <Flex gap="md" align="center">
{weekdays ? ( {weekdays ? (
<Badge color="blue">Weekdays</Badge> <Badge color="orange.7">Weekdays</Badge>
) : null} ) : null}
{weekends ? ( {weekends ? (
<Badge color="blue">Weekends</Badge> <Badge color="orange.7">Weekends</Badge>
) : null} ) : null}
<RingProgress <RingProgress
size={30} size={30}
thickness={5} thickness={5}
label={ label={
<Text size="xs" ml="lg"> <Text size="xs" ml="lg">
{probability * 100}% {probability * 100}%
</Text> </Text>
} }
sections={[ sections={[
{ color: "cyan", value: probability * 100 }, {
]} color: "orange",
/> value: probability * 100,
</Flex> },
</> ]}
) : null} />
{punishment_pool_name ? ( </Flex>
<Text flex={1}> </>
<b>Punishments:</b> {punishment_pool_name}
</Text>
) : null}
<Flex justify="end" align="flex-end" gap="md">
{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}
text={`Are you sure you want to delete ${name}?`} </Text>
onConfirm={() => handleDelete(id)} ) : null}
> <Flex justify="end" align="flex-end" gap="sm">
<IconTrash /> {permissions?.can_details && orders.length > 0 ? (
</ConfirmDialogButton> <DonutChart
<NavigateButton to={`/orders/${username}/${id}`}> flex={1}
<IconPencil style={{ marginRight: "0.5rem" }} /> size={130}
Edit thickness={30}
</NavigateButton> data={orders.map(({ name, weight }, idx) => ({
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,6 +1,7 @@
import React from "react"; import React from "react";
import { useUserContext } from "./UserContext"; import { useUserContext } from "./UserContext";
import { import {
Anchor,
Avatar, Avatar,
Box, Box,
Button, Button,
@ -134,9 +135,11 @@ export const Profile: React.FC = () => {
<Title>{username}</Title> <Title>{username}</Title>
</Flex> </Flex>
<Box mb="md"> <Box mb="md">
<Link to={`/dashboard`}>Return to dashboard</Link> <Anchor component={Link} to={`/dashboard/`}>
Return to dashboard
</Anchor>
</Box> </Box>
<Paper bg="gray.1"> <Paper withBorder>
<Title order={4}>Mastodon</Title> <Title order={4}>Mastodon</Title>
<TextInput label="Account" w="50%" value={mastodon_account} /> <TextInput label="Account" w="50%" value={mastodon_account} />
<Button onClick={open}>Authorize with Mastodon</Button> <Button onClick={open}>Authorize with Mastodon</Button>

View file

@ -0,0 +1,129 @@
import React from "react";
import { Button, Checkbox, Paper, Title } from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { fetchHeaders } from "./fetch";
type ProfilePermissionsProps = Pick<
UserProfile,
| "permission_orders_pools_view"
| "permission_orders_pools_details"
| "permission_orders_pools_edit"
> & { username: string };
type ProfilePermissionsForm = Pick<
UserProfile,
| "permission_orders_pools_view"
| "permission_orders_pools_details"
| "permission_orders_pools_edit"
>;
export const ProfilePermissions: React.FC<ProfilePermissionsProps> = ({
username,
permission_orders_pools_view,
permission_orders_pools_details,
permission_orders_pools_edit,
}) => {
const [loading, setLoading] = React.useState(false);
const [detailsDisabled, setDetailsDisabled] = React.useState(
!permission_orders_pools_view,
);
const [editDisabled, setEditDisabled] = React.useState(
!permission_orders_pools_details,
);
const form = useForm<ProfilePermissionsForm>({
mode: "uncontrolled",
initialValues: {
permission_orders_pools_view,
permission_orders_pools_details,
permission_orders_pools_edit,
},
});
form.watch("permission_orders_pools_view", ({ value }) => {
if (!value) {
form.setFieldValue("permission_orders_pools_details", false);
setDetailsDisabled(true);
} else {
setDetailsDisabled(false);
}
});
form.watch("permission_orders_pools_details", ({ value }) => {
if (!value) {
form.setFieldValue("permission_orders_pools_edit", false);
setEditDisabled(true);
} else {
setEditDisabled(false);
}
});
const handleSubmit = React.useCallback(
form.onSubmit((values) => {
setLoading(true);
fetch(`/api/subs/${username}`, {
method: "POST",
headers: fetchHeaders(),
body: JSON.stringify(values),
})
.then((response) => {
if (response.ok) {
notifications.show({
title: "Success",
message: "Permissions have been saved",
color: "green",
});
} else {
notifications.show({
title: "Error",
message: "There was a problem saving permissions",
color: "red",
});
}
})
.finally(() => {
setLoading(false);
});
}),
[],
);
return (
<Paper>
<form onSubmit={handleSubmit}>
<Title order={4} mb="md">
Permissions
</Title>
<Checkbox
{...form.getInputProps("permission_orders_pools_view", {
type: "checkbox",
})}
key={form.key("permission_orders_pools_view")}
label="Sub can view orders sets"
mb="md"
/>
<Checkbox
{...form.getInputProps("permission_orders_pools_details", {
type: "checkbox",
})}
key={form.key("permission_orders_pools_details")}
disabled={detailsDisabled}
label="Sub can view orders sets details"
mb="md"
/>
<Checkbox
{...form.getInputProps("permission_orders_pools_edit", {
type: "checkbox",
})}
key={form.key("permission_orders_pools_edit")}
disabled={editDisabled}
label="Sub can edit orders sets"
mb="md"
/>
<Button type="submit" loading={loading} mt="md">
Save
</Button>
</form>
</Paper>
);
};

View file

@ -75,7 +75,7 @@ export const ProfileVerification: React.FC<ProfileVerificationProps> = ({
); );
return ( return (
<Paper bg="gray.1"> <Paper>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Title order={4} mb="md"> <Title order={4} mb="md">
Order Verification Order Verification

View file

@ -1,46 +0,0 @@
import React from "react";
import { Params, useLoaderData, useParams, Link } from "react-router";
import { OrderSetProps, OrderSets } from "./OrderSets";
import { ProfileVerification } from "./ProfileVerification";
import { Title } from "@mantine/core";
export const subOrderSetsLoader = async ({
params: { username },
}: {
params: Params<string>;
}) => fetch(`/api/orders/${username}/sets`).then((response) => response.json());
export const SubOrderSets: React.FC = () => {
const { username: sub_username } = useParams();
const orderSets = useLoaderData<OrderSetProps["orderSets"]>();
const [profile, setProfile] = React.useState<UserProfile | null>(null);
React.useEffect(() => {
fetch(`/api/subs/${sub_username}`)
.then((response) => response.json())
.then(setProfile);
}, [sub_username]);
return (
<>
<OrderSets
username={sub_username}
orderSets={orderSets}
linkBack={<Link to={`/dashboard/`}>Return to dashboard</Link>}
/>
{profile ? (
<>
<Title order={1} mb="md">
Sub Profile
</Title>
<ProfileVerification
username={sub_username}
verify_mastodon_alt_text={profile.verify_mastodon_alt_text}
verify_mastodon_favorite={profile.verify_mastodon_favorite}
verify_delay={profile.verify_delay}
/>
</>
) : null}
</>
);
};

View file

@ -1,4 +1,4 @@
import { Timeline, Text, Title, Box, Flex } from "@mantine/core"; import { Timeline, Text, Title, Box, Flex, Card, Anchor } from "@mantine/core";
import React from "react"; import React from "react";
import moment from "moment"; import moment from "moment";
import { Link } from "react-router"; import { Link } from "react-router";
@ -14,6 +14,7 @@ import {
IconLock, IconLock,
IconX, IconX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useUserContext } from "./UserContext";
const TIMELINE_TYPE = { const TIMELINE_TYPE = {
ORDER_NOT_ISSUED: { ORDER_NOT_ISSUED: {
@ -28,27 +29,27 @@ const TIMELINE_TYPE = {
}, },
ORDER_CONFIRMED: { ORDER_CONFIRMED: {
title: "Order confirmed", title: "Order confirmed",
color: "green.6", color: "green.9",
bullet: <IconCheck />, bullet: <IconCheck />,
}, },
ORDER_NOT_PUNISHED: { ORDER_NOT_PUNISHED: {
title: "Order not punished", title: "Order not punished",
color: "gray.6", color: "gray.8",
bullet: null, bullet: null,
}, },
ORDER_PUNISHED: { ORDER_PUNISHED: {
title: "Order punished", title: "Order punished",
color: "red.6", color: "red.8",
bullet: <IconAlertCircle />, bullet: <IconAlertCircle />,
}, },
ORDERS_POOL_CREATED: { ORDERS_POOL_CREATED: {
title: "Orders pool created", title: "Orders pool created",
color: "green.3", color: "green.6",
bullet: <IconFilePlus />, bullet: <IconFilePlus />,
}, },
ORDERS_POOL_UPDATED: { ORDERS_POOL_UPDATED: {
title: "Orders pool updated", title: "Orders pool updated",
color: "green.3", color: "green.6",
bullet: <IconFileDiff />, bullet: <IconFileDiff />,
}, },
ORDERS_POOL_DELETED: { ORDERS_POOL_DELETED: {
@ -60,60 +61,81 @@ const TIMELINE_TYPE = {
export const TimelineList: React.FC<{ export const TimelineList: React.FC<{
timeline: TimelineEvent[]; timeline: TimelineEvent[];
}> = ({ timeline }) => ( }> = ({ timeline }) => {
<Box> const { username: my_username } = useUserContext();
<Title order={1} mb="lg">
Timeline return (
</Title> <Box>
<Timeline bulletSize={24} lineWidth={2} my="lg"> <Title order={1} mb="lg">
{timeline.map( Timeline
({ </Title>
id, <Card py="xs">
updated_at, <Timeline bulletSize={24} lineWidth={2} my="lg">
type, {timeline.map(
text, ({
username, id,
actor_username, updated_at,
orders_pool, type,
extra, text,
}) => ( username,
<Timeline.Item key={id} active {...(TIMELINE_TYPE[type] ?? {})}> actor_username,
<Text size="sm"> orders_pool,
{text.split("\n").map((str, idx) => ( extra,
<React.Fragment key={idx}> }) => (
{str} <Timeline.Item key={id} active {...(TIMELINE_TYPE[type] ?? {})}>
<br /> <Text size="sm">
</React.Fragment> {text.split("\n").map((str, idx) => (
))} <React.Fragment key={idx}>
</Text> {str}
<Flex mt={4} gap="xs"> <br />
{extra?.mastodon_status_url ? ( </React.Fragment>
<Text size="xs"> ))}
<a href={extra.mastodon_status_url} target="_blank">
Mastodon Post <IconExternalLink size="0.75rem" />
</a>
</Text> </Text>
) : null} <Flex mt={4} gap="xs">
{orders_pool ? ( {extra?.mastodon_status_url ? (
<Text size="xs"> <Text size="xs">
<Link to={`/orders/${username}/${orders_pool.id}`}> <Anchor href={extra.mastodon_status_url} target="_blank">
{orders_pool.name} Mastodon Post <IconExternalLink size="0.75rem" />
</Link> </Anchor>
</Text> </Text>
) : null} ) : null}
{actor_username ? ( {orders_pool ? (
<Text size="xs"> <Text size="xs">
<IconCrown size="0.75rem" /> {actor_username} {orders_pool.id ? (
</Text> <Anchor
) : null} component={Link}
<Text size="xs"> to={`/orders/${username}/${orders_pool.id}`}
<IconLock size="0.75rem" /> {username} >
</Text> {orders_pool.name}
<Text size="xs">{moment(updated_at).fromNow()}</Text> </Anchor>
</Flex> ) : (
</Timeline.Item> orders_pool.name
), )}
)} </Text>
</Timeline> ) : null}
</Box> {actor_username ? (
); <Text size="xs">
<IconCrown size="0.75rem" /> {actor_username}
</Text>
) : null}
<Text size="xs">
{username != my_username ? (
<Anchor component={Link} to={`/orders/${username}`}>
<IconLock size="0.75rem" /> {username}
</Anchor>
) : (
<>
<IconLock size="0.75rem" /> {username}
</>
)}
</Text>
<Text size="xs">{moment(updated_at).fromNow()}</Text>
</Flex>
</Timeline.Item>
),
)}
</Timeline>
</Card>
</Box>
);
};

View file

@ -0,0 +1,68 @@
import React from "react";
import { Params, useLoaderData, useParams, Link } from "react-router";
import { OrderSetProps, OrderSets, OrderSetsResponse } from "./OrderSets";
import { ProfileVerification } from "./ProfileVerification";
import { Anchor, Title } from "@mantine/core";
import { ProfilePermissions } from "./ProfilePermissions";
export const userOrderSetsLoader = async ({
params: { username },
}: {
params: Params<string>;
}) => fetch(`/api/orders/${username}/sets`).then((response) => response.json());
export const UserOrderSets: React.FC = () => {
const { username: sub_username } = useParams();
const orderSets = useLoaderData<OrderSetsResponse>();
const [profile, setProfile] = React.useState<UserProfile | null>(null);
React.useEffect(() => {
fetch(`/api/subs/${sub_username}`).then(async (response) => {
if (response.ok) {
setProfile(await response.json());
}
});
}, [sub_username]);
return (
<>
<OrderSets
username={sub_username}
orderSets={orderSets.pools}
permissions={orderSets.permissions}
linkBack={
<Anchor component={Link} to={`/dashboard/`}>
Return to dashboard
</Anchor>
}
/>
{profile ? (
<>
<Title order={1} mb="md">
Sub Profile
</Title>
<ProfileVerification
username={sub_username}
verify_mastodon_alt_text={profile.verify_mastodon_alt_text}
verify_mastodon_favorite={profile.verify_mastodon_favorite}
verify_delay={profile.verify_delay}
/>
{profile.can_edit_permissions ? (
<ProfilePermissions
username={sub_username}
permission_orders_pools_view={
profile.permission_orders_pools_view
}
permission_orders_pools_details={
profile.permission_orders_pools_details
}
permission_orders_pools_edit={
profile.permission_orders_pools_edit
}
/>
) : null}
</>
) : null}
</>
);
};

View file

@ -9,6 +9,10 @@ type UserProfile = {
verify_mastodon_alt_text?: boolean; verify_mastodon_alt_text?: boolean;
verify_mastodon_favorite?: boolean; verify_mastodon_favorite?: boolean;
verify_delay?: number; verify_delay?: number;
can_edit_permissions?: boolean;
permission_orders_pools_view?: boolean;
permission_orders_pools_details?: boolean;
permission_orders_pools_edit?: boolean;
} }
type OrderSetOrderAddOn = { type OrderSetOrderAddOn = {

View file

@ -8,16 +8,20 @@ import {
Input, Input,
Paper, Paper,
Slider, Slider,
Image,
mergeThemeOverrides,
} from "@mantine/core"; } from "@mantine/core";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import { mantineTheme } from "./theme";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css"; 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";
@ -46,6 +50,11 @@ const theme = createTheme({
}, },
}) as any, }) as any,
}), }),
Image: Image.extend({
defaultProps: {
radius: "xs",
},
}),
}, },
}); });
@ -66,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",
@ -83,9 +92,11 @@ const router = createBrowserRouter([
}, },
]); ]);
const mergedTheme = mergeThemeOverrides(mantineTheme, theme);
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<MantineProvider theme={theme}> <MantineProvider defaultColorScheme="dark" theme={mergedTheme}>
<UserContextProvider> <UserContextProvider>
<Notifications /> <Notifications />
<RouterProvider router={router} /> <RouterProvider router={router} />

78
web/vite/src/theme.ts Normal file
View file

@ -0,0 +1,78 @@
import { Card, Container, createTheme, Paper, rem, Select } from "@mantine/core";
import type { MantineThemeOverride } from "@mantine/core";
const CONTAINER_SIZES: Record<string, string> = {
xxs: rem("200px"),
xs: rem("300px"),
sm: rem("400px"),
md: rem("500px"),
lg: rem("600px"),
xl: rem("1400px"),
xxl: rem("1600px"),
};
export const mantineTheme: MantineThemeOverride = createTheme({
/** Put your mantine theme override here */
fontSizes: {
xs: rem("12px"),
sm: rem("14px"),
md: rem("16px"),
lg: rem("18px"),
xl: rem("20px"),
"2xl": rem("24px"),
"3xl": rem("30px"),
"4xl": rem("36px"),
"5xl": rem("48px"),
},
spacing: {
"3xs": rem("4px"),
"2xs": rem("8px"),
xs: rem("10px"),
sm: rem("12px"),
md: rem("16px"),
lg: rem("20px"),
xl: rem("24px"),
"2xl": rem("28px"),
"3xl": rem("32px"),
},
primaryColor: "orange",
components: {
/** Put your mantine component override here */
Container: Container.extend({
vars: (_, { size, fluid }) => ({
root: {
"--container-size": fluid
? "100%"
: size !== undefined && size in CONTAINER_SIZES
? CONTAINER_SIZES[size]
: rem(size),
},
}),
}),
Paper: Paper.extend({
defaultProps: {
p: "md",
shadow: "xl",
radius: "md",
withBorder: true,
},
}),
Card: Card.extend({
defaultProps: {
p: "xl",
shadow: "xl",
radius: "var(--mantine-radius-default)",
withBorder: true,
},
}),
Select: Select.extend({
defaultProps: {
checkIconPosition: "right",
},
}),
},
other: {
style: "mantine",
},
});