diff --git a/rsbbs/config.py b/rsbbs/config.py index 0d6a71d..7170d7b 100644 --- a/rsbbs/config.py +++ b/rsbbs/config.py @@ -48,6 +48,7 @@ class Config(): def __getattr__(self, __name: str): return self.config[__name] + # Format the config for display def __repr__(self): repr = [] repr.append(f"app_name: {self.app_name}\r\n") diff --git a/rsbbs/console.py b/rsbbs/console.py index 9e4644d..f7eb548 100644 --- a/rsbbs/console.py +++ b/rsbbs/console.py @@ -16,11 +16,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os import sys -import sqlalchemy.exc - import rsbbs from rsbbs.config import Config from rsbbs.controller import Controller @@ -134,22 +131,6 @@ class Console(): f"{datetime_: <{11}} " f"{message.Message.subject}") - # - # Command functions - # - - def send_private(self, args): - self.send(args, is_private=True) - """Send a message visible only to the recipient callsign. - - Required arguments: - callsign -- the recipient's callsign - - Optional arguments: - subject -- message subject - message -- the message itself - """ - # # Main input loop # diff --git a/rsbbs/controller.py b/rsbbs/controller.py index be6e663..8e98e29 100644 --- a/rsbbs/controller.py +++ b/rsbbs/controller.py @@ -16,42 +16,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import subprocess - -from datetime import datetime, timezone - -from sqlalchemy import Boolean, DateTime, String -from sqlalchemy import create_engine, delete, select, or_ - -from sqlalchemy.orm import DeclarativeBase, Mapped, Session -from sqlalchemy.orm import mapped_column +from sqlalchemy import create_engine +from sqlalchemy.orm import Session from rsbbs.config import Config - - -class Base(DeclarativeBase): - pass - - -class Message(Base): - __tablename__ = 'message' - id: Mapped[int] = mapped_column(primary_key=True) - sender: Mapped[str] = mapped_column(String) - recipient: Mapped[str] = mapped_column(String) - subject: Mapped[str] = mapped_column(String) - message: Mapped[str] = mapped_column(String) - datetime: Mapped[DateTime] = mapped_column( - DateTime, default=datetime.now(timezone.utc)) - is_private: Mapped[bool] = mapped_column(Boolean) +from rsbbs.models import Base class Controller(): - def __init__(self, config: Config): + def __init__(self, config: Config) -> None: self.config = config self._init_datastore() - def _init_datastore(self): + def _init_datastore(self) -> None: """Create a connection to the sqlite3 database. The default location is the system-specific user-level data directory. @@ -64,103 +42,5 @@ class Controller(): # Create the database schema if none exists Base.metadata.create_all(self.engine) - def delete(self, args): - """Delete a message. - - Arguments: - number -- the message number to delete - """ - with Session(self.engine) as session: - try: - message = session.get(Message, args.number) - session.delete(message) - session.commit() - except Exception: - raise - - def delete_mine(self, args): - """Delete all messages addressed to the calling station's callsign.""" - with Session(self.engine) as session: - try: - statement = delete(Message).where( - Message.recipient == self.config.calling_station - ).returning(Message) - result = session.execute( - statement, - execution_options={"prebuffer_rows": True}) - session.commit() - return result - except Exception: - raise - - def list(self, args): - """List all messages.""" - with Session(self.engine) as session: - try: - # Using or_ and is_ etc. to distinguish from python operators - statement = select(Message).where( - or_( - (Message.is_private.is_(False)), - (Message.recipient.__eq__( - self.config.calling_station))) - ) - result = session.execute( - statement, - execution_options={"prebuffer_rows": True}) - except Exception: - raise - return result - - def list_mine(self, args): - """List only messages addressed to the calling station's callsign, - including public and private messages. - """ - with Session(self.engine) as session: - try: - statement = select(Message).where( - Message.recipient == self.config.calling_station) - result = session.execute( - statement, - execution_options={"prebuffer_rows": True}) - return result - except Exception: - raise - - def read(self, args): - """Read a message. - - Arguments: - number -- the message number to read - """ - with Session(self.engine) as session: - try: - statement = select(Message).where(Message.id == args.number) - result = session.execute(statement).one() - return result - except Exception: - raise - - def send(self, args, is_private=False): - """Create a new message addressed to another callsign. - - Required arguments: - callsign -- the recipient's callsign - - Optional arguments: - subject -- message subject - message -- the message itself - """ - with Session(self.engine) as session: - try: - session.add(Message( - sender=self.config.calling_station.upper(), - recipient=args.callsign.upper(), - subject=args.subject, - message=args.message, - is_private=is_private - )) - session.commit() - return {} - except Exception: - session.rollback() - raise + def session(self) -> Session: + return Session(self.engine) diff --git a/rsbbs/models.py b/rsbbs/models.py new file mode 100644 index 0000000..941c68b --- /dev/null +++ b/rsbbs/models.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# +# Really Simple BBS - a really simple BBS for ax.25 packet radio. +# Copyright (C) 2023 John Burwell +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, String + +from sqlalchemy.orm import DeclarativeBase, Mapped +from sqlalchemy.orm import mapped_column + + +class Base(DeclarativeBase): + pass + + +class Message(Base): + __tablename__ = 'message' + id: Mapped[int] = mapped_column(primary_key=True) + sender: Mapped[str] = mapped_column(String) + recipient: Mapped[str] = mapped_column(String) + subject: Mapped[str] = mapped_column(String) + message: Mapped[str] = mapped_column(String) + datetime: Mapped[DateTime] = mapped_column( + DateTime, default=datetime.now(timezone.utc)) + is_private: Mapped[bool] = mapped_column(Boolean) diff --git a/rsbbs/plugins/bye/plugin.py b/rsbbs/plugins/bye/plugin.py index 9bc5a41..e7aab51 100644 --- a/rsbbs/plugins/bye/plugin.py +++ b/rsbbs/plugins/bye/plugin.py @@ -22,20 +22,20 @@ from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='bye', aliases=['b', 'q'], help='Sign off and disconnect') subparser.set_defaults(func=self.run) - def run(self, args): + def run(self, args) -> None: """Disconnect and exit.""" self.api.write_output("Bye!") exit(0) diff --git a/rsbbs/plugins/delete/plugin.py b/rsbbs/plugins/delete/plugin.py index 1b1e417..d7a3374 100644 --- a/rsbbs/plugins/delete/plugin.py +++ b/rsbbs/plugins/delete/plugin.py @@ -16,19 +16,23 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import sqlalchemy +import sqlalchemy.exc + from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='delete', aliases=['d', 'k'], @@ -37,11 +41,23 @@ class Plugin(): help='The number of the message to delete') subparser.set_defaults(func=self.run) - def run(self, args): - """Delete a message specified by ID number.""" - if args.number: + def delete(self, number) -> None: + with self.api.controller.session() as session: try: - self.api.controller.delete(args) - self.api.write_output(f"Deleted message #{args.number}") - except Exception as e: + message = session.get(Message, number) + session.delete(message) + session.commit() + self.api.write_output(f"Deleted message #{number}") + except sqlalchemy.exc.NoResultFound: self.api.write_output(f"Message not found.") + except Exception as e: + print(e) + + def run(self, args) -> None: + """Delete a message. + + Arguments: + number -- the message number to delete + """ + if args.number: + self.delete(args.number) diff --git a/rsbbs/plugins/deletem/plugin.py b/rsbbs/plugins/deletem/plugin.py index 0020a6f..964058d 100644 --- a/rsbbs/plugins/deletem/plugin.py +++ b/rsbbs/plugins/deletem/plugin.py @@ -16,34 +16,38 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import sqlalchemy + from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='deletem', aliases=['dm', 'km'], help='Delete all messages addressed to you') subparser.set_defaults(func=self.run) - def run(self, args): - """Delete all messages addressed to the calling station's callsign.""" - response = self.api.read_line( - "Delete all messages addressed to you? Y/N:") - if response.lower() != "y": - return - else: + def delete_mine(self) -> None: + with self.api.controller.session() as session: try: - result = self.api.controller.delete_mine(args) + statement = sqlalchemy.delete(Message).where( + Message.recipient == self.api.config.calling_station + ).returning(Message) + result = session.execute( + statement, + execution_options={"prebuffer_rows": True}) + session.commit() messages = result.all() count = len(messages) if count > 0: @@ -52,3 +56,10 @@ class Plugin(): self.api.write_output(f"No messages to delete.") except Exception as e: self.api.write_output(f"Unable to delete messages: {e}") + + def run(self, args) -> None: + """Delete all messages addressed to the calling station's callsign.""" + response = self.api.read_line( + "Delete all messages addressed to you? Y/N:") + if response.lower() == "y": + self.delete_mine() diff --git a/rsbbs/plugins/heard/plugin.py b/rsbbs/plugins/heard/plugin.py index f7bf783..abc9094 100644 --- a/rsbbs/plugins/heard/plugin.py +++ b/rsbbs/plugins/heard/plugin.py @@ -24,20 +24,20 @@ from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='heard', aliases=['j'], help='Show heard stations log') subparser.set_defaults(func=self.run) - def run(self, args): + def run(self, args) -> None: """Show a log of stations that have been heard by this station, also known as the 'mheard' (linux) or 'jheard' (KPC, etc.) log. """ diff --git a/rsbbs/plugins/help/plugin.py b/rsbbs/plugins/help/plugin.py index 6974f0d..266ebc5 100644 --- a/rsbbs/plugins/help/plugin.py +++ b/rsbbs/plugins/help/plugin.py @@ -22,20 +22,20 @@ from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='help', aliases=['h', '?'], help='Show help') subparser.set_defaults(func=self.run) - def run(self, args): + def run(self, args) -> None: """Show a log of stations that have been heard by this station, also known as the 'mheard' (linux) or 'jheard' (KPC, etc.) log. """ diff --git a/rsbbs/plugins/list/plugin.py b/rsbbs/plugins/list/plugin.py index 44b9901..efd48c5 100644 --- a/rsbbs/plugins/list/plugin.py +++ b/rsbbs/plugins/list/plugin.py @@ -16,26 +16,47 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import sqlalchemy + from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='list', aliases=['l'], help='List all available messages') subparser.set_defaults(func=self.run) - def run(self, args): + def list(self, args) -> sqlalchemy.ChunkedIteratorResult: + """List all messages.""" + with self.api.controller.session() as session: + try: + # Using or_ and is_ etc. to distinguish from python operators + statement = sqlalchemy.select(Message).where( + sqlalchemy.or_( + (Message.is_private.is_(False)), + (Message.recipient.__eq__( + self.api.config.calling_station))) + ) + result = session.execute( + statement, + execution_options={"prebuffer_rows": True}) + except Exception: + raise + return result + + def run(self, args) -> None: """List all public messages and messages private to the caller.""" - result = self.api.controller.list(args) + result = self.list(args) self.api.print_message_list(result) diff --git a/rsbbs/plugins/listm/plugin.py b/rsbbs/plugins/listm/plugin.py index 321b354..40578e1 100644 --- a/rsbbs/plugins/listm/plugin.py +++ b/rsbbs/plugins/listm/plugin.py @@ -16,28 +16,43 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import sqlalchemy + from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='listm', aliases=['lm'], help='List only messages addressed to you') subparser.set_defaults(func=self.run) + def list_mine(self, args) -> sqlalchemy.ChunkedIteratorResult: + with self.api.controller.session() as session: + try: + statement = sqlalchemy.select(Message).where( + Message.recipient == self.api.config.calling_station) + result = session.execute( + statement, + execution_options={"prebuffer_rows": True}) + return result + except Exception: + raise + def run(self, args): """List only messages addressed to the calling station's callsign, including public and private messages. """ - result = self.api.controller.list_mine(args) + result = self.list_mine(args) self.api.print_message_list(result) diff --git a/rsbbs/plugins/read/plugin.py b/rsbbs/plugins/read/plugin.py index 9d88aaf..e88160e 100644 --- a/rsbbs/plugins/read/plugin.py +++ b/rsbbs/plugins/read/plugin.py @@ -17,20 +17,22 @@ # along with this program. If not, see . import sqlalchemy +import sqlalchemy.exc from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='read', aliases=['r'], @@ -38,17 +40,23 @@ class Plugin(): subparser.add_argument('number', help='Message number to read') subparser.set_defaults(func=self.run) - def run(self, args): + def read_message(self, number) -> None: + with self.api.controller.session() as session: + try: + statement = sqlalchemy.select(Message).where( + Message.id == number) + result = session.execute(statement).one() + self.api.print_message(result) + except sqlalchemy.exc.NoResultFound: + self.api.write_output(f"Message not found.") + except Exception as e: + print(e) + + def run(self, args) -> None: """Read a message. Arguments: number -- the message number to read """ if args.number: - try: - result = self.api.controller.read(args) - self.api.print_message(result) - except sqlalchemy.exc.NoResultFound: - self.api.write_output(f"Message not found.") - except Exception as e: - print(e) + self.read_message(args.number) diff --git a/rsbbs/plugins/readm/plugin.py b/rsbbs/plugins/readm/plugin.py index 292c9a9..27e2e24 100644 --- a/rsbbs/plugins/readm/plugin.py +++ b/rsbbs/plugins/readm/plugin.py @@ -19,28 +19,41 @@ import sqlalchemy from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='readm', aliases=['rm'], help='Read all messages addressed to you') subparser.set_defaults(func=self.run) - def run(self, args): + def list_mine(self, args) -> sqlalchemy.ChunkedIteratorResult: + with self.api.controller.session() as session: + try: + statement = sqlalchemy.select(Message).where( + Message.recipient == self.api.config.calling_station) + result = session.execute( + statement, + execution_options={"prebuffer_rows": True}) + return result + except Exception: + raise + + def run(self, args) -> None: """Read all messages addressed to the calling station's callsign, in sequence.""" - result = self.api.controller.list_mine(args) + result = self.list_mine(args) messages = result.all() count = len(messages) if count > 0: diff --git a/rsbbs/plugins/send/plugin.py b/rsbbs/plugins/send/plugin.py index 2acd1e3..f187800 100644 --- a/rsbbs/plugins/send/plugin.py +++ b/rsbbs/plugins/send/plugin.py @@ -16,21 +16,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sqlalchemy - from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='send', aliases=['s'], @@ -40,7 +39,22 @@ class Plugin(): subparser.add_argument('--message', help='Message') subparser.set_defaults(func=self.run) - def run(self, args): + def send(self, args, is_private=False) -> None: + with self.api.controller.session() as session: + try: + session.add(Message( + sender=self.api.config.calling_station.upper(), + recipient=args.callsign.upper(), + subject=args.subject, + message=args.message, + is_private=is_private + )) + session.commit() + except Exception: + session.rollback() + raise + + def run(self, args) -> None: """Create a new message addressed to another callsign. Required arguments: @@ -58,6 +72,6 @@ class Plugin(): args.message = self.api.read_multiline( "Message - end with /ex on a single line:") try: - self.api.controller.send(args, is_private=False) + self.send(args) except Exception as e: print(e) diff --git a/rsbbs/plugins/sendp/plugin.py b/rsbbs/plugins/sendp/plugin.py index f036e61..57a3b8a 100644 --- a/rsbbs/plugins/sendp/plugin.py +++ b/rsbbs/plugins/sendp/plugin.py @@ -16,21 +16,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sqlalchemy - from rsbbs.console import Console +from rsbbs.models import Message from rsbbs.parser import Parser class Plugin(): - def __init__(self, api: Console): + def __init__(self, api: Console) -> None: self.api = api self.init_parser(api.parser) if api.config.debug: print(f"Plugin {__name__} loaded") - def init_parser(self, parser: Parser): + def init_parser(self, parser: Parser) -> None: subparser = parser.subparsers.add_parser( name='sendp', aliases=['sp'], @@ -40,7 +39,22 @@ class Plugin(): subparser.add_argument('--message', help='Message') subparser.set_defaults(func=self.run) - def run(self, args): + def send(self, args, is_private=False) -> None: + with self.api.controller.session() as session: + try: + session.add(Message( + sender=self.api.config.calling_station.upper(), + recipient=args.callsign.upper(), + subject=args.subject, + message=args.message, + is_private=is_private + )) + session.commit() + except Exception: + session.rollback() + raise + + def run(self, args) -> None: """Create a new message addressed to another callsign. Required arguments: @@ -58,6 +72,6 @@ class Plugin(): args.message = self.api.read_multiline( "Message - end with /ex on a single line:") try: - self.api.controller.send(args, is_private=True) + self.send(args, is_private=True) except Exception as e: print(e) diff --git a/rsbbs/rsbbs.py b/rsbbs/rsbbs.py index 94e7220..36679fa 100755 --- a/rsbbs/rsbbs.py +++ b/rsbbs/rsbbs.py @@ -23,7 +23,6 @@ from rsbbs import __version__ from rsbbs.config import Config from rsbbs.console import Console from rsbbs.controller import Controller -from rsbbs.parser import Parser def parse_args():