Generate orders from db

This commit is contained in:
Johnny Gear 2026-03-04 17:33:17 -06:00
parent fd654153a4
commit 18b9c9de55
13 changed files with 403 additions and 114 deletions

5
.gitignore vendored
View file

@ -1,7 +1,10 @@
*.pyc
.env
*~
db.sqlite3
Caddyfile
*.dump.sql
*.dump.sql
.vscode

View file

@ -104,9 +104,24 @@ class OrderStatus(BaseModel):
created_at = DateTimeField() # TIMESTAMP
due_at = DateTimeField() # TIMESTAMP
mastodon_id = TextField()
punishment = ForeignKeyField(column_name='punishment_id', field='id', model=PunishmentStatus, null=True)
text = TextField()
pool = ForeignKeyField(
column_name='orders_pool_id',
field='id',
model=OrdersPool,
backref='statuses',
null=True
)
user = ForeignKeyField(
column_name='user_id',
field='id',
model=User,
backref='statuses',
null=False
)
class Meta:
table_name = 'order_status'
@ -115,6 +130,15 @@ class Repeat(BaseModel):
orders = TextField()
probability = FloatField()
orders_pool = ForeignKeyField(
column_name='orders_pool_id',
field='id',
model=OrdersPool,
null=False,
unique=True,
backref='repeat'
)
class Meta:
table_name = 'repeat'

View file

@ -28,6 +28,12 @@ def orders_pool_list(user_id):
def orders_pool(user_id, set_id):
return OrdersPool.get(OrdersPool.user_id == user_id, OrdersPool.id == set_id)
def orders_pool_by_id(pool_id):
return OrdersPool.get(OrdersPool.id == pool_id)
def orders_pool_scheduled():
return OrdersPool.select().where(OrdersPool.scheduled == True)
def domsubusers_add(sub, dom):
return DomSubUsers.create(
sub=sub,
@ -43,23 +49,27 @@ def domsubusers_delete(sub, dom):
def domsubusers_list(dom):
return DomSubUsers.select().where(DomSubUsers.dom == dom)
def repeat_get():
def repeat_get(orders_pool_id):
try:
return Repeat.get()
return Repeat.get(orders_pool_id = orders_pool_id)
except Repeat.DoesNotExist:
return None
def repeat_increment():
q = Repeat.update(count=Repeat.count + 1)
return q.execute()
def repeat_increment(id):
r = Repeat.get(id=id)
q = Repeat.update(
count = r.count + 1
)
q.execute()
def repeat_put(probability, orders):
def repeat_put(orders_pool_id, probability, orders):
return Repeat.create(
orders_pool_id=orders_pool_id,
probability=probability,
orders=orders
)
def repeat_clear():
def repeat_clear(id):
q = Repeat.delete()
q.execute()
@ -80,14 +90,19 @@ def skip_day_contains(date):
q = SkipDay.select().where(SkipDay.date == date)
return len(q) > 0
def order_status_put(mastodon_id, created_at, due_at, text):
def order_status_put(orders_pool, user, mastodon_id, created_at, due_at, text):
return OrderStatus.create(
orders_pool_id=orders_pool.id,
user_id=user.id,
mastodon_id=mastodon_id,
created_at=created_at,
due_at=due_at,
text=text
)
def order_status_by_id(order_status_id):
return OrderStatus.get(id=order_status_id)
def order_status_outstanding():
return OrderStatus.select().where(
(OrderStatus.confirmed_at.is_null()) & (OrderStatus.punishment_id.is_null())

View file

@ -10,30 +10,18 @@ logger = logging.getLogger(__name__)
def pick_order(orders):
picked = random.choices(orders,
weights=[o['weight'] for o in orders],
weights=[o.weight for o in orders],
k=1)[0]
result = []
repeat = 0.0
if('text' in picked):
result.append(picked['text'])
result.append(picked.name)
if('repeat' in picked):
repeat = picked['repeat']
for add_on in picked.add_ons:
if add_on.probability > random.random():
result.append(add_on.name)
if('add' in picked):
for addition in picked['add']:
if addition['probability'] > random.random():
result.append(addition['text'])
if('pick' in picked):
(new_result, new_repeat) = pick_order(picked['pick'])
result += new_result
if new_repeat > 0.0:
repeat = new_repeat
return (result, repeat)
return (result, picked.repeat)
def read_config():
with open(ORDERS_YML) as stream:
@ -43,31 +31,29 @@ def read_config():
logger.error(exc)
return orders
def generate_order():
def generate_order(orders_pool):
# Do we want to repeat?
repeat = repeat_get()
if repeat is not None:
if orders_pool.repeat.count() != 0:
repeat = orders_pool.repeat.get()
if repeat.probability > random.random():
repeat_increment()
repeat_increment(repeat.id)
return {
"orders": json.loads(repeat.orders),
"count": repeat.count
}
else:
repeat_clear()
orders_config = read_config()
repeat_clear(repeat.id)
if orders_config['orders_probability'] < random.random():
if orders_pool.probability < random.random():
# No orders today
return { }
# Pick new orders
(result, repeat_p) = pick_order(orders_config['orders'])
(result, repeat_p) = pick_order(orders_pool.orders)
# Log the repeat
if repeat_p > 0.0:
repeat_put(repeat_p, json.dumps(result))
repeat_put(orders_pool.id, repeat_p, json.dumps(result))
return {
"orders": result

41
main.py
View file

@ -2,15 +2,25 @@ import sys
import logging
import argparse
import asyncio
import json
from scheduling import OrderScheduler
from generate import generate_order
from orders import order_issue, order_check
from db.queries import orders_pool_by_id
from telegram.telegram import handle_commands
from telegram.commands import commands
from db.queries import initdb
logger = logging.getLogger(__name__)
async def do_order_issue(order_pool_id):
orders_pool = orders_pool_by_id(args.orders_pool_id)
order_status = await order_issue(orders_pool)
if order_status is not None:
logger.info(f'Issued order id {order_status.id}')
if __name__=='__main__':
logging.basicConfig(
format="%(asctime)s %(module)s [%(levelname)-4.4s] %(message)s",
@ -23,22 +33,31 @@ if __name__=='__main__':
subparsers = parser.add_subparsers(help="Sub-command help", dest="command")
parser_immediate = subparsers.add_parser('immediate', help='Immediately generate a command')
parser_check = subparsers.add_parser('check', help="Checks if any orders are outstanding")
parser_initdb = subparsers.add_parser('initdb', help="Creates the database tables")
parser_generate = subparsers.add_parser('generate', help='Generate a command')
parser_generate.add_argument('orders_pool_id')
parser_issue = subparsers.add_parser('issue', help='Issue a command')
parser_issue.add_argument('orders_pool_id')
parser_issue = subparsers.add_parser('check', help='Check on the status of an order')
parser_issue.add_argument('order_status_id')
args = parser.parse_args()
if args.command == 'immediate':
if args.command == 'generate':
orders_pool = orders_pool_by_id(args.orders_pool_id)
logger.info('Orders - %s', json.dumps(generate_order(orders_pool)))
elif args.command == 'issue':
loop = asyncio.new_event_loop()
loop.run_until_complete(order_issue())
loop.close()
loop.run_until_complete(
do_order_issue(args.orders_pool_id)
)
elif args.command == 'check':
loop = asyncio.new_event_loop()
loop.run_until_complete(order_check())
loop.close()
elif args.command == 'initdb':
initdb()
loop.run_until_complete(
order_check(args.order_status_id)
)
else:
loop = asyncio.new_event_loop()
s = OrderScheduler(loop)

View file

@ -0,0 +1,54 @@
"""Peewee migrations -- 005_delete_repeat.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.remove_model('repeat')
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
@migrator.create_model
class Repeat(pw.Model):
id = pw.AutoField()
count = pw.IntegerField(default=0)
orders = pw.TextField()
probability = pw.FloatField()
class Meta:
table_name = "repeat"

View file

@ -0,0 +1,55 @@
"""Peewee migrations -- 006_create_repeat.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.create_model
class Repeat(pw.Model):
id = pw.AutoField()
count = pw.IntegerField(default=0)
orders = pw.TextField()
probability = pw.FloatField()
orders_pool = pw.ForeignKeyField(column_name='orders_pool_id', field='id', model=migrator.orm['orders_pool'], unique=True)
class Meta:
table_name = "repeat"
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('repeat')

View file

@ -0,0 +1,57 @@
"""Peewee migrations -- 007_delete_order_status.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.remove_model('order_status')
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
@migrator.create_model
class OrderStatus(pw.Model):
id = pw.AutoField()
confirmed_at = pw.DateTimeField(null=True)
created_at = pw.DateTimeField()
due_at = pw.DateTimeField()
mastodon_id = pw.TextField()
punishment = pw.ForeignKeyField(column_name='punishment_id', field='id', model=migrator.orm['punishment_status'], null=True)
text = pw.TextField()
class Meta:
table_name = "order_status"

View file

@ -0,0 +1,58 @@
"""Peewee migrations -- 008_add_order_status.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.create_model
class OrderStatus(pw.Model):
id = pw.AutoField()
confirmed_at = pw.DateTimeField(null=True)
created_at = pw.DateTimeField()
due_at = pw.DateTimeField()
mastodon_id = pw.TextField()
text = pw.TextField()
pool = pw.ForeignKeyField(column_name='orders_pool_id', field='id', model=migrator.orm['orders_pool'], null=True)
user = pw.ForeignKeyField(column_name='user_id', field='id', model=migrator.orm['user'])
class Meta:
table_name = "order_status"
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('order_status')

View file

@ -4,7 +4,7 @@ import asyncio
from util import make_session
from generate import generate_order, generate_punishment
from db.queries import order_status_put, punishment_status_put, order_status_outstanding, order_status_confirm
from db.queries import order_status_by_id, order_status_put, punishment_status_put, order_status_outstanding, order_status_confirm
from mastodon import Mastodon
from telegram.telegram import Telegram
from settings import MASTODON_USERNAME, ORDER_TIMEOUT, ENV
@ -12,7 +12,8 @@ from util import timezone
logger = logging.getLogger(__name__)
async def order_mastodon_post(session, orders_str, repeats, due_at):
async def order_mastodon_post(session, orders_pool, orders_str, repeats, due_at):
# TODO: Get user's mastodon username
post = "Here are today's orders for @%s -\n\n" % MASTODON_USERNAME
post += orders_str + "\n\n"
if repeats > 1:
@ -26,7 +27,7 @@ async def order_mastodon_post(session, orders_str, repeats, due_at):
m = Mastodon(session)
return await m.statusPost(post)
async def order_telegram_post(session, orders_str, repeats, due_at, m_url):
async def order_telegram_post(session, orders_pool, orders_str, repeats, due_at, m_url):
post = "Here are your orders -\n\n"
post += orders_str + "\n\n"
if repeats > 1:
@ -37,23 +38,23 @@ async def order_telegram_post(session, orders_str, repeats, due_at, m_url):
post += "\n⚠️ DEV"
t = Telegram(session)
await t.message_send(post)
await t.message_send(orders_pool.user.telegram_chat_id, post)
async def order_telegram_post_none(session):
async def order_telegram_post_none(session, orders_pool):
post = "No orders for today"
if ENV == 'dev':
post += "\n⚠️ DEV"
t = Telegram(session)
await t.message_send(post)
await t.message_send(orders_pool.user.telegram_chat_id, post)
async def order_issue():
async def order_issue(orders_pool):
async with make_session() as session:
orders_info = generate_order()
orders_info = generate_order(orders_pool)
if 'orders' not in orders_info:
logger.info("No orders for today")
await order_telegram_post_none(session)
await order_telegram_post_none(session, orders_pool)
return
orders_str = "\n".join(orders_info['orders'])
@ -65,6 +66,7 @@ async def order_issue():
m_status = await order_mastodon_post(
session,
orders_pool,
orders_str,
repeats_count,
due_at
@ -72,20 +74,21 @@ async def order_issue():
await order_telegram_post(
session,
orders_pool,
orders_str,
repeats_count,
due_at,
m_status['url']
)
order_status_put(
return order_status_put(
orders_pool,
orders_pool.user,
m_status['id'],
created_at,
due_at,
orders_str
)
return due_at
)
async def punishment_mastodon_post(session, punishment_str, reply_id=None):
post = "@%s has failed to post proof of compliance. Here is the punishment -\n\n" % MASTODON_USERNAME
@ -112,6 +115,11 @@ async def punishment_telegram_post(session, punishment_str, m_url):
await t.message_send(post)
async def punishment_issue(session, outstanding_order):
# TODO: Generate a punishment
logger.info('TODO: Generate a punishment')
return
punishment = generate_punishment()
punishment_str = "\n".join(punishment)
@ -135,31 +143,32 @@ async def punishment_issue(session, outstanding_order):
)
order_check_lock = asyncio.Lock()
async def order_check():
async def order_check(order_status_id):
async with order_check_lock:
async with make_session() as session:
outstanding_orders = order_status_outstanding()
for outstanding_order in outstanding_orders:
m = Mastodon(session)
context = await m.statusContext(outstanding_order.mastodon_id)
order_status = order_status_by_id(order_status_id)
confirmed_at = None
for d in context['descendants']:
if (
d['in_reply_to_id'] == outstanding_order.mastodon_id and
d['account']['username'] == MASTODON_USERNAME and
len(d['media_attachments']) > 0
):
confirmed_at = d['created_at']
order_status_confirm(outstanding_order.id, confirmed_at)
logger.info('Confirmed order %s' % (outstanding_order.id))
break
m = Mastodon(session)
context = await m.statusContext(order_status.mastodon_id)
confirmed_at = None
for d in context['descendants']:
if (
d['in_reply_to_id'] == order_status.mastodon_id and
# TODO: Get mastodon username
d['account']['username'] == MASTODON_USERNAME and
len(d['media_attachments']) > 0
):
confirmed_at = d['created_at']
order_status_confirm(order_status.id, confirmed_at)
logger.info('Confirmed order %s' % (order_status.id))
break
if confirmed_at is None:
logger.info('Order %s remains unconfirmed' % (outstanding_order.id))
if confirmed_at is None:
logger.info('Order %s remains unconfirmed' % (order_status.id))
due_at = datetime.datetime.fromisoformat(outstanding_order.due_at)
if(due_at < datetime.datetime.now(datetime.UTC)):
logger.info('Time to issue a punishment for %s' % outstanding_order.id)
due_at = datetime.datetime.fromisoformat(order_status.due_at)
if(due_at < datetime.datetime.now(datetime.UTC)):
logger.info('Time to issue a punishment for %s' % order_status.id)
await punishment_issue(session, outstanding_order)
await punishment_issue(session, order_status)

View file

@ -6,13 +6,13 @@ from scheduler.asyncio import Scheduler
from settings import TIMEZONE
from orders import order_issue, order_check
from db.queries import order_status_outstanding, skip_day_contains
from db.queries import order_status_outstanding, orders_pool_by_id, orders_pool_scheduled, skip_day_contains
from util import order_time
logger = logging.getLogger(__name__)
SATURDAY = 5
SUNDAY = 6
WEEKDAYS = [0, 1, 2, 3, 4]
WEEKENDS = [5, 6]
GRACE_PERIOD = datetime.timedelta(seconds=10)
@ -22,44 +22,53 @@ class OrderScheduler():
self.scheduler = Scheduler(loop=loop, tzinfo=self.tz)
# Regularly scheduled orders
order_time_dt = order_time()
self.scheduler.daily(order_time_dt, self.scheduled_order)
self.scheduled_pools = {}
for orders_pool in orders_pool_scheduled():
self.scheduled_pools[orders_pool.id] = self.scheduler.daily(
order_time(orders_pool.time),
self.scheduled_order,
args=(orders_pool.id,)
)
# Schedule any outstanding orders
outstanding_orders = order_status_outstanding()
for oo in outstanding_orders:
self.scheduler.once(
datetime.datetime.fromisoformat(oo.due_at) + GRACE_PERIOD,
self.scheduled_check
)
self.outstanding_orders = {}
# TODO: Schedule outstanding order checks
# TODO: Schedule keeping schedule up to date
logger.info(self.scheduler)
async def scheduled_order(self):
# Skip weekends
async def scheduled_order(self, orders_pool_id):
orders_pool = orders_pool_by_id(orders_pool_id)
# Skip weekends or weekdays
day_of_week = datetime.datetime.now(tz=self.tz).weekday()
if (day_of_week in [SATURDAY, SUNDAY]):
logger.info('Today is a weekend')
if (
(not orders_pool.weekends and day_of_week in WEEKENDS)
or
(not orders_pool.weekdays and day_of_week in WEEKDAYS)
):
logger.info(f'{orders_pool.name}[{orders_pool.user.telegram_username}] Not scheduled for today')
return
# Skip stored dates
# TODO: Store these per user
today = datetime.datetime.now(tz=self.tz).strftime("%Y-%m-%d")
logger.info('Today %s' % today)
if (skip_day_contains(today)):
logger.info('Today is a skip day')
return
logger.info(f'Issuing order for {orders_pool.name}[{orders_pool.user.telegram_username}]')
due_at = await order_issue()
order_status = await order_issue(orders_pool)
if due_at is not None:
if order_status is not None:
# Schedule check
self.scheduler.once(
due_at + GRACE_PERIOD,
self.scheduled_check
self.outstanding_orders[order_status.id] = self.scheduler.once(
order_status.due_at + GRACE_PERIOD,
self.scheduled_check,
args=(order_status.id,)
)
logger.info(self.scheduler)
async def scheduled_check(self):
await order_check()
async def scheduled_check(self, outstanding_order_id):
await order_check(outstanding_order_id)

View file

@ -39,8 +39,8 @@ class Telegram:
return await response.json()
async def message_send(self, text, chat_id=None):
chat_id_actual = chat_id if chat_id != None else TELEGRAM_CHAT_ID
async def message_send(self, chat_id, text):
chat_id_actual = chat_id
text_actual = text
if ENV == 'dev' and chat_id != TELEGRAM_CHAT_ID:
text_actual = f"⚠️ Message intended for chat id {chat_id}\n\n" + text

View file

@ -10,8 +10,8 @@ def make_session():
def timezone():
return pytz.timezone(TIMEZONE)
def order_time():
order_time_arr = list(map(int, ORDER_TIME.split(':')))
def order_time(str):
order_time_arr = list(map(int, str.split(':')))
return datetime.time(
hour=order_time_arr[0],
minute=order_time_arr[1],