mirror of https://gitlab.com/tildes/tildes.git
Browse Source
Add scheduled topics (no UI yet)
Add scheduled topics (no UI yet)
This adds the backend for scheduled topics, which can be set up to post at a certain time and then (optionally) repeat on a schedule. Currently, these topics must be text topics, and can have their title, markdown, and tags set up. They can be configured to be posted by a particular user, but if no user is chosen they will be posted by a (newly added) generic user named "Tildes" that is intended to be used for "owning" automatic actions like this.merge-requests/85/head
Deimos
5 years ago
11 changed files with 283 additions and 3 deletions
-
5salt/salt/cronjobs.sls
-
75tildes/alembic/versions/20b5f07e5f80_add_topic_schedule_table.py
-
1tildes/requirements-to-freeze.txt
-
43tildes/scripts/post_scheduled_topics.py
-
4tildes/sql/init/insert_base_data.sql
-
15tildes/tests/test_datetime.py
-
8tildes/tildes/database_models.py
-
27tildes/tildes/lib/database.py
-
11tildes/tildes/lib/datetime.py
-
1tildes/tildes/models/topic/__init__.py
-
96tildes/tildes/models/topic/topic_schedule.py
@ -0,0 +1,75 @@ |
|||
"""Add topic_schedule table |
|||
|
|||
Revision ID: 20b5f07e5f80 |
|||
Revises: d56e71257a86 |
|||
Create Date: 2019-10-02 22:08:13.324006 |
|||
|
|||
""" |
|||
from alembic import op |
|||
import sqlalchemy as sa |
|||
|
|||
from tildes.lib.database import ArrayOfLtree |
|||
|
|||
|
|||
# revision identifiers, used by Alembic. |
|||
revision = "20b5f07e5f80" |
|||
down_revision = "d56e71257a86" |
|||
branch_labels = None |
|||
depends_on = None |
|||
|
|||
|
|||
def upgrade(): |
|||
op.create_table( |
|||
"topic_schedule", |
|||
sa.Column("schedule_id", sa.Integer(), nullable=False), |
|||
sa.Column("group_id", sa.Integer(), nullable=False), |
|||
sa.Column("user_id", sa.Integer(), nullable=True), |
|||
sa.Column( |
|||
"created_time", |
|||
sa.TIMESTAMP(timezone=True), |
|||
server_default=sa.text("NOW()"), |
|||
nullable=False, |
|||
), |
|||
sa.Column("title", sa.Text(), nullable=False), |
|||
sa.Column("markdown", sa.Text(), nullable=False), |
|||
sa.Column("tags", ArrayOfLtree(), server_default="{}", nullable=False), |
|||
sa.Column("next_post_time", sa.TIMESTAMP(timezone=True), nullable=True), |
|||
sa.Column("recurrence_rule", sa.Text(), nullable=True), |
|||
sa.ForeignKeyConstraint( |
|||
["group_id"], |
|||
["groups.group_id"], |
|||
name=op.f("fk_topic_schedule_group_id_groups"), |
|||
), |
|||
sa.ForeignKeyConstraint( |
|||
["user_id"], ["users.user_id"], name=op.f("fk_topic_schedule_user_id_users") |
|||
), |
|||
sa.PrimaryKeyConstraint("schedule_id", name=op.f("pk_topic_schedule")), |
|||
) |
|||
op.create_index( |
|||
op.f("ix_topic_schedule_group_id"), "topic_schedule", ["group_id"], unique=False |
|||
) |
|||
op.create_index( |
|||
op.f("ix_topic_schedule_next_post_time"), |
|||
"topic_schedule", |
|||
["next_post_time"], |
|||
unique=False, |
|||
) |
|||
op.create_check_constraint("title_length", "topic_schedule", "LENGTH(title) <= 200") |
|||
|
|||
# add the generic Tildes user (used to post scheduled topics by default) |
|||
op.execute( |
|||
""" |
|||
INSERT INTO users (user_id, username, password_hash) |
|||
VALUES(-1, 'Tildes', '') |
|||
ON CONFLICT DO NOTHING |
|||
""" |
|||
) |
|||
|
|||
|
|||
def downgrade(): |
|||
op.drop_constraint("ck_topic_schedule_title_length", "topic_schedule") |
|||
op.drop_index(op.f("ix_topic_schedule_next_post_time"), table_name="topic_schedule") |
|||
op.drop_index(op.f("ix_topic_schedule_group_id"), table_name="topic_schedule") |
|||
op.drop_table("topic_schedule") |
|||
|
|||
# don't delete the Tildes user, won't work if it posted any topics |
@ -0,0 +1,43 @@ |
|||
# Copyright (c) 2019 Tildes contributors <code@tildes.net> |
|||
# SPDX-License-Identifier: AGPL-3.0-or-later |
|||
|
|||
"""Script to post any scheduled topics that are due.""" |
|||
|
|||
from typing import Optional |
|||
|
|||
from sqlalchemy.orm.session import Session |
|||
|
|||
from tildes.lib.database import get_session_from_config |
|||
from tildes.lib.datetime import utc_now |
|||
from tildes.models.topic import TopicSchedule |
|||
|
|||
|
|||
def _get_next_due_topic(db_session: Session) -> Optional[TopicSchedule]: |
|||
"""Get the next due topic (if any). |
|||
|
|||
Note that this also locks the topic's row with FOR UPDATE as well as using SKIP |
|||
LOCKED. This should (hypothetically) mean that multiple instances of this script |
|||
can run concurrently safely and will not attempt to post the same topics. |
|||
""" |
|||
return ( |
|||
db_session.query(TopicSchedule) |
|||
.filter(TopicSchedule.next_post_time <= utc_now()) # type: ignore |
|||
.order_by(TopicSchedule.next_post_time) |
|||
.with_for_update(skip_locked=True) |
|||
.first() |
|||
) |
|||
|
|||
|
|||
def post_scheduled_topics(config_path: str) -> None: |
|||
"""Post all scheduled topics that are due to be posted.""" |
|||
db_session = get_session_from_config(config_path) |
|||
|
|||
due_topic = _get_next_due_topic(db_session) |
|||
|
|||
while due_topic: |
|||
db_session.add(due_topic.create_topic()) |
|||
due_topic.advance_schedule() |
|||
db_session.add(due_topic) |
|||
db_session.commit() |
|||
|
|||
due_topic = _get_next_due_topic(db_session) |
@ -0,0 +1,96 @@ |
|||
# Copyright (c) 2019 Tildes contributors <code@tildes.net> |
|||
# SPDX-License-Identifier: AGPL-3.0-or-later |
|||
|
|||
"""Contains the TopicSchedule class.""" |
|||
|
|||
from datetime import datetime |
|||
from typing import List, Optional |
|||
|
|||
from dateutil.rrule import rrule |
|||
from sqlalchemy import CheckConstraint, Column, ForeignKey, Integer, Text, TIMESTAMP |
|||
from sqlalchemy.orm import relationship |
|||
from sqlalchemy.orm.session import Session |
|||
from sqlalchemy.sql.expression import text |
|||
from sqlalchemy_utils import Ltree |
|||
|
|||
from tildes.lib.database import ArrayOfLtree, RecurrenceRule |
|||
from tildes.models import DatabaseModel |
|||
from tildes.models.group import Group |
|||
from tildes.models.topic import Topic |
|||
from tildes.models.user import User |
|||
from tildes.schemas.topic import TITLE_MAX_LENGTH |
|||
|
|||
|
|||
class TopicSchedule(DatabaseModel): |
|||
"""Model for scheduled topics (auto-posted, often repeatedly on a schedule).""" |
|||
|
|||
__tablename__ = "topic_schedule" |
|||
|
|||
schedule_id: int = Column(Integer, primary_key=True) |
|||
group_id: int = Column( |
|||
Integer, ForeignKey("groups.group_id"), nullable=False, index=True |
|||
) |
|||
user_id: Optional[int] = Column(Integer, ForeignKey("users.user_id"), nullable=True) |
|||
created_time: datetime = Column( |
|||
TIMESTAMP(timezone=True), nullable=False, server_default=text("NOW()") |
|||
) |
|||
title: str = Column( |
|||
Text, |
|||
CheckConstraint(f"LENGTH(title) <= {TITLE_MAX_LENGTH}", name="title_length"), |
|||
nullable=False, |
|||
) |
|||
markdown: str = Column(Text, nullable=False) |
|||
tags: List[Ltree] = Column(ArrayOfLtree, nullable=False, server_default="{}") |
|||
next_post_time: Optional[datetime] = Column( |
|||
TIMESTAMP(timezone=True), nullable=True, index=True |
|||
) |
|||
recurrence_rule: Optional[rrule] = Column(RecurrenceRule, nullable=True) |
|||
|
|||
group: Group = relationship("Group", innerjoin=True) |
|||
user: Optional[User] = relationship("User") |
|||
|
|||
def __init__( |
|||
self, |
|||
group: Group, |
|||
title: str, |
|||
markdown: str, |
|||
tags: List[str], |
|||
next_post_time: datetime, |
|||
recurrence_rule: Optional[rrule] = None, |
|||
user: Optional[User] = None, |
|||
) -> None: |
|||
"""Create a new scheduled topic.""" |
|||
# pylint: disable=too-many-arguments |
|||
self.group = group |
|||
self.title = title |
|||
self.markdown = markdown |
|||
self.tags = [Ltree(tag) for tag in tags] |
|||
self.next_post_time = next_post_time |
|||
self.recurrence_rule = recurrence_rule |
|||
self.user = user |
|||
|
|||
def create_topic(self) -> Topic: |
|||
"""Create and return an actual Topic for this scheduled topic.""" |
|||
# if no user is specified, use the "generic"/automatic user (ID -1) |
|||
if self.user: |
|||
user = self.user |
|||
else: |
|||
user = ( |
|||
Session.object_session(self) |
|||
.query(User) |
|||
.filter(User.user_id == -1) |
|||
.one() |
|||
) |
|||
|
|||
topic = Topic.create_text_topic(self.group, user, self.title, self.markdown) |
|||
topic.tags = [str(tag) for tag in self.tags] |
|||
|
|||
return topic |
|||
|
|||
def advance_schedule(self) -> None: |
|||
"""Advance the schedule, setting next_post_time appropriately.""" |
|||
if self.recurrence_rule: |
|||
rule = self.recurrence_rule.replace(dtstart=self.next_post_time) |
|||
self.next_post_time = rule.after(self.next_post_time) |
|||
else: |
|||
self.next_post_time = None |
Write
Preview
Loading…
Cancel
Save
Reference in new issue