From a767efb904a02613706da0cbf425c79f103aef72 Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 17:10:39 +0200 Subject: [PATCH 01/24] added ".vscode" directory and any ".env" file --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 597aceb..c2484e0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,6 @@ htmlcov/ dist/ build/ *.egg-info/ -.env +*.env the_works.sqlite -.vscode/extensions.json +.vscode/ From 6f3e53273d70db55a78c778d84babe644ec47cb1 Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 17:21:14 +0200 Subject: [PATCH 02/24] changed how the app gets its config values --- .flaskenv | 13 +++++++++++++ the_works/__init__.py | 30 ++++++++++++++---------------- 2 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 .flaskenv diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..2081c72 --- /dev/null +++ b/.flaskenv @@ -0,0 +1,13 @@ +# Non-critical configuration values +# Read automatically if python-dotenv is installed +FLASK_APP = "the_works" +FLASK_ENV = "development" +FLASK_SECRET_KEY = "f8148ee5d95b0a67122b1cab9993f637a6bf29528f584a9f1575af1a55566748" +FLASK_TESTING = False +FLASK_MAX_CONTENT_LENGTH = 1024 * 1024 +FLASK_DEBUG = True + +FLASK_SQLALCHEMY_DATABASE_URI = "sqlite:///../the_works.sqlite" +FLASK_SQLALCHEMY_ECHO = False +FLASK_SQLALCHEMY_RECORD_QUERIES = True + diff --git a/the_works/__init__.py b/the_works/__init__.py index 1916033..100651f 100644 --- a/the_works/__init__.py +++ b/the_works/__init__.py @@ -4,16 +4,23 @@ from flask import Flask from the_works.database import init_db from flask_debugtoolbar import DebugToolbarExtension -def create_app(): +def create_app(config=None): app = Flask(__name__) - # read config values - load_dotenv() + # read all config values from environment that are prefixed with "FLASK_" app.config.from_prefixed_env() - if os.getenv("SQLALCHEMY_DATABASE_DIALECT") == "sqlite": - app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:///" + os.path.abspath(app.root_path + "/..") + "/" + os.getenv("SQLALCHEMY_DATABASE_SQLITE_FILENAME") - else: - exit("no SQLite database URI given; exiting") + + # some #DEBUG configuration +# toolbar = DebugToolbarExtension(app) #DEBUG + app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False #DEBUG + + # use config from function parameter if present + if config is not None: + app.config.from_object(config) + + # some #DEBUG output + print(f"Current Environment: {app.config['ENV']}") #DEBUG + print(app.config) #DEBUG # initialize database init_db(app) @@ -34,15 +41,6 @@ def create_app(): app.register_blueprint(veroeffentlichung.bp) app.register_blueprint(titelbild.bp) - app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 - - ### DEBUG -# toolbar = DebugToolbarExtension(app) - app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False - app.config["SQLALCHEMY_ECHO"] = True - app.config['SQLALCHEMY_RECORD_QUERIES'] = os.getenv("SQLALCHEMY_RECORD_QUERIES") - print(f"Current Environment: " + app.config['ENVIRONMENT']) - # register helper function app.jinja_env.globals.update(sizeof_fmt=sizeof_fmt) From 1111a35f3d3e013bfc51af6e4d0c7fdf541a7849 Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 17:48:37 +0200 Subject: [PATCH 03/24] added info about configuring the app --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d55231..ad25c63 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ # The Works +## Configuration + +The file `.flaskenv` contains the default configuration. Flask reads the file at startup and adds the contained key-value-pairs as environment variables to the runtime environment. When the Flask app object is being created in `__init__.py`, all environment variables that start with the prefix "FLASK_" are added to the app configuration. This is true for any prefixed environment variable, not just the ones from `.flaskenv`. + +Configuration values can be overridden by passing another config file to Flask via the `-e` command line switch. It will be processed the same way as `.flaskenv`, which means that all keys must be prefixed with "FLASK_", and takes precedence over the default configuration. + +Finally, you can override config settings while instantiating the Flask app through the factory. To do this, simply pass a dictionary with the (unprefixed) override settings as named parameter `config` to the `create_app()` method. Settings passed this way take precedence over the default configuration and an optional config file. + + + + ## Flask commands -Execute commands with `python -m flask --app the_works ` +Execute commands with `python -m flask `. You don't need to specify `--app the_works` as long as the environment variable "FLASK_APP" is set to "the_works"; the default configuration file does this. Available commands: @@ -12,6 +23,8 @@ Available commands: * + + ## Dependencies ### Python Packages From c295651d8338ad6589b39f8c219091ab6fda9d5b Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 21:21:41 +0200 Subject: [PATCH 04/24] make applying config values more robust --- the_works/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/the_works/__init__.py b/the_works/__init__.py index 100651f..e1c6b24 100644 --- a/the_works/__init__.py +++ b/the_works/__init__.py @@ -15,11 +15,11 @@ def create_app(config=None): app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False #DEBUG # use config from function parameter if present - if config is not None: - app.config.from_object(config) + if config: + app.config.update(config) # some #DEBUG output - print(f"Current Environment: {app.config['ENV']}") #DEBUG + print(f"Current Environment: {app.config['ENV'] if 'ENV' in app.config.keys() else 'ENV is not set'}") #DEBUG print(app.config) #DEBUG # initialize database From be55f16181ec9fd3d987e4a46ad5a2bdfbac825b Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 21:23:29 +0200 Subject: [PATCH 05/24] renamed file --- utils/sqlacodegen_output.py | 181 ------------------------------------ utils/sqlacodegen_tables.py | 123 ++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 181 deletions(-) delete mode 100644 utils/sqlacodegen_output.py create mode 100644 utils/sqlacodegen_tables.py diff --git a/utils/sqlacodegen_output.py b/utils/sqlacodegen_output.py deleted file mode 100644 index 86497dd..0000000 --- a/utils/sqlacodegen_output.py +++ /dev/null @@ -1,181 +0,0 @@ -from typing import List, Optional - -from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, Table, Text -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - -class Base(DeclarativeBase): - pass - - -class Genre(Base): - __tablename__ = 'Genre' - - Genre: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Text_: Mapped[List['Text_']] = relationship('Text_', secondary='Text_Genre', back_populates='Genre_') - Werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Genre', back_populates='Genre_') - - -class Herausgeber(Base): - __tablename__ = 'Herausgeber' - - Name: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Herausgeber', back_populates='Herausgeber_') - - -class Pseudonym(Base): - __tablename__ = 'Pseudonym' - - Pseudonym: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='Pseudonym1') - - -class Sprache(Base): - __tablename__ = 'Sprache' - - Sprache: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Text_: Mapped[List['Text_']] = relationship('Text_', back_populates='Sprache1') - - -class Textform(Base): - __tablename__ = 'Textform' - - Textform: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Text_: Mapped[List['Text_']] = relationship('Text_', back_populates='Textform1') - - -class Titelbild(Base): - __tablename__ = 'Titelbild' - - Mimetype: Mapped[str] = mapped_column(Text) - Dateiname: Mapped[str] = mapped_column(Text) - Dateigroesse: Mapped[int] = mapped_column(Integer) - Breite: Mapped[int] = mapped_column(Integer) - Hoehe: Mapped[int] = mapped_column(Integer) - Bild: Mapped[bytes] = mapped_column(LargeBinary) - Thumbnail: Mapped[bytes] = mapped_column(LargeBinary) - sha256: Mapped[str] = mapped_column(Text, unique=True) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Titelbild1') - - -class Verlag(Base): - __tablename__ = 'Verlag' - - Verlag: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Reihe: Mapped[List['Reihe']] = relationship('Reihe', back_populates='Verlag1') - Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Verlag1') - - -class Werksform(Base): - __tablename__ = 'Werksform' - - Werksform: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - - Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Werksform1') - - -class Reihe(Base): - __tablename__ = 'Reihe' - - Titel: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - Verlag_: Mapped[Optional[str]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) - - Verlag1: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='Reihe') - Text_: Mapped[List['Text_']] = relationship('Text_', back_populates='Reihe1') - Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Reihe1') - - -class Text_(Base): - __tablename__ = 'Text' - - Titel: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - Untertitel: Mapped[Optional[str]] = mapped_column(Text) - Reihe_: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) - Textform_: Mapped[Optional[int]] = mapped_column('Textform', ForeignKey('Textform.ID')) - Sprache_: Mapped[Optional[int]] = mapped_column('Sprache', ForeignKey('Sprache.ID')) - - Genre_: Mapped[List['Genre']] = relationship('Genre', secondary='Text_Genre', back_populates='Text_') - Reihe1: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='Text_') - Sprache1: Mapped[Optional['Sprache']] = relationship('Sprache', back_populates='Text_') - Textform1: Mapped[Optional['Textform']] = relationship('Textform', back_populates='Text_') - Veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='Text2') - - -class Werk(Base): - __tablename__ = 'Werk' - - Titel: Mapped[str] = mapped_column(Text) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - Untertitel: Mapped[Optional[str]] = mapped_column(Text) - Werksform_: Mapped[Optional[int]] = mapped_column('Werksform', ForeignKey('Werksform.ID')) - Verlag_: Mapped[Optional[int]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) - Reihe_: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) - Reihennummer: Mapped[Optional[str]] = mapped_column(Text) - Erscheinungsdatum: Mapped[Optional[str]] = mapped_column(Text) - ISBN_13: Mapped[Optional[str]] = mapped_column(Text) - ISBN_10: Mapped[Optional[str]] = mapped_column(Text) - ISSN: Mapped[Optional[str]] = mapped_column(Text) - Preis: Mapped[Optional[str]] = mapped_column(Text) - Titelbild_: Mapped[Optional[int]] = mapped_column('Titelbild', ForeignKey('Titelbild.ID')) - Klappentext: Mapped[Optional[str]] = mapped_column(Text) - Anmerkungen: Mapped[Optional[str]] = mapped_column(Text) - - Genre_: Mapped[List['Genre']] = relationship('Genre', secondary='Werk_Genre', back_populates='Werk') - Herausgeber_: Mapped[List['Herausgeber']] = relationship('Herausgeber', secondary='Werk_Herausgeber', back_populates='Werk') - Reihe1: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='Werk') - Titelbild1: Mapped[Optional['Titelbild']] = relationship('Titelbild', back_populates='Werk') - Verlag1: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='Werk') - Werksform1: Mapped[Optional['Werksform']] = relationship('Werksform', back_populates='Werk') - Veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='Werk1') - - -t_Text_Genre = Table( - 'Text_Genre', Base.metadata, - Column('Text', ForeignKey('Text.ID'), primary_key=True), - Column('Genre', ForeignKey('Genre.ID'), primary_key=True) -) - - -class Veroeffentlichung(Base): - __tablename__ = 'Veroeffentlichung' - - Text1: Mapped[int] = mapped_column('Text', ForeignKey('Text.ID')) - Werk_: Mapped[int] = mapped_column('Werk', ForeignKey('Werk.ID')) - Pseudonym_: Mapped[int] = mapped_column('Pseudonym', ForeignKey('Pseudonym.ID')) - ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) - AltTitel: Mapped[Optional[str]] = mapped_column(Text) - AltUntertitel: Mapped[Optional[str]] = mapped_column(Text) - - Pseudonym1: Mapped['Pseudonym'] = relationship('Pseudonym', back_populates='Veroeffentlichung') - Text2: Mapped['Text_'] = relationship('Text_', back_populates='Veroeffentlichung') - Werk1: Mapped['Werk'] = relationship('Werk', back_populates='Veroeffentlichung') - - -t_Werk_Genre = Table( - 'Werk_Genre', Base.metadata, - Column('Werk', ForeignKey('Werk.ID'), primary_key=True), - Column('Genre', ForeignKey('Genre.ID'), primary_key=True) -) - - -t_Werk_Herausgeber = Table( - 'Werk_Herausgeber', Base.metadata, - Column('Herausgeber', ForeignKey('Herausgeber.ID'), primary_key=True), - Column('Werk', ForeignKey('Werk.ID'), primary_key=True) -) diff --git a/utils/sqlacodegen_tables.py b/utils/sqlacodegen_tables.py new file mode 100644 index 0000000..2b4ccb8 --- /dev/null +++ b/utils/sqlacodegen_tables.py @@ -0,0 +1,123 @@ +from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, MetaData, Table, Text + +metadata = MetaData() + + +t_Genre = Table( + 'Genre', metadata, + Column('ID', Integer, primary_key=True), + Column('Genre', Text, nullable=False) +) + +t_Herausgeber = Table( + 'Herausgeber', metadata, + Column('ID', Integer, primary_key=True), + Column('Name', Text, nullable=False) +) + +t_Pseudonym = Table( + 'Pseudonym', metadata, + Column('ID', Integer, primary_key=True), + Column('Pseudonym', Text, nullable=False) +) + +t_Sprache = Table( + 'Sprache', metadata, + Column('ID', Integer, primary_key=True), + Column('Sprache', Text, nullable=False) +) + +t_Textform = Table( + 'Textform', metadata, + Column('ID', Integer, primary_key=True), + Column('Textform', Text, nullable=False) +) + +t_Titelbild = Table( + 'Titelbild', metadata, + Column('ID', Integer, primary_key=True), + Column('Mimetype', Text, nullable=False), + Column('Dateiname', Text, nullable=False), + Column('Dateigroesse', Integer, nullable=False), + Column('Breite', Integer, nullable=False), + Column('Hoehe', Integer, nullable=False), + Column('Bild', LargeBinary, nullable=False), + Column('Thumbnail', LargeBinary, nullable=False), + Column('sha256', Text, nullable=False, unique=True) +) + +t_Verlag = Table( + 'Verlag', metadata, + Column('ID', Integer, primary_key=True), + Column('Verlag', Text, nullable=False) +) + +t_Werksform = Table( + 'Werksform', metadata, + Column('ID', Integer, primary_key=True), + Column('Werksform', Text, nullable=False) +) + +t_Reihe = Table( + 'Reihe', metadata, + Column('ID', Integer, primary_key=True), + Column('Titel', Text, nullable=False), + Column('Verlag', ForeignKey('Verlag.ID')) +) + +t_Text = Table( + 'Text', metadata, + Column('ID', Integer, primary_key=True), + Column('Titel', Text, nullable=False), + Column('Untertitel', Text), + Column('Reihe', ForeignKey('Reihe.ID')), + Column('Textform', ForeignKey('Textform.ID')), + Column('Sprache', ForeignKey('Sprache.ID')) +) + +t_Werk = Table( + 'Werk', metadata, + Column('ID', Integer, primary_key=True), + Column('Titel', Text, nullable=False), + Column('Untertitel', Text), + Column('Werksform', ForeignKey('Werksform.ID')), + Column('Verlag', ForeignKey('Verlag.ID')), + Column('Reihe', ForeignKey('Reihe.ID')), + Column('Reihennummer', Text), + Column('Erscheinungsdatum', Text), + Column('ISBN_13', Text), + Column('ISBN_10', Text), + Column('ISSN', Text), + Column('Preis', Text), + Column('Titelbild', ForeignKey('Titelbild.ID')), + Column('Klappentext', Text), + Column('Anmerkungen', Text) +) + +t_Text_Genre = Table( + 'Text_Genre', metadata, + Column('Text', ForeignKey('Text.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) +) + +t_Veroeffentlichung = Table( + 'Veroeffentlichung', metadata, + Column('ID', Integer, primary_key=True), + Column('Text', ForeignKey('Text.ID'), nullable=False), + Column('Werk', ForeignKey('Werk.ID'), nullable=False), + Column('AltTitel', Text), + Column('AltUntertitel', Text), + Column('Pseudonym', ForeignKey('Pseudonym.ID'), nullable=False) +) + +t_Werk_Genre = Table( + 'Werk_Genre', metadata, + Column('Werk', ForeignKey('Werk.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) +) + +t_Werk_Herausgeber = Table( + 'Werk_Herausgeber', metadata, + Column('Herausgeber', ForeignKey('Herausgeber.ID'), primary_key=True), + Column('Werk', ForeignKey('Werk.ID'), primary_key=True) +) From 2f29aa4ff637ac6f6c7757fdbda78699e8c9385a Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 21:26:04 +0200 Subject: [PATCH 06/24] split generated code into tables and declarative ORM classes --- utils/sqlacodegen_declarative.py | 183 +++++++++++++++++++++++++++++++ utils/sqlacodegen_tables.py | 2 + 2 files changed, 185 insertions(+) create mode 100644 utils/sqlacodegen_declarative.py diff --git a/utils/sqlacodegen_declarative.py b/utils/sqlacodegen_declarative.py new file mode 100644 index 0000000..70013ad --- /dev/null +++ b/utils/sqlacodegen_declarative.py @@ -0,0 +1,183 @@ +# File contains output of `sqlacodegen --generator declarative sqlite:///path/to/the_works.sqlite` + +from typing import List, Optional + +from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, Table, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +class Base(DeclarativeBase): + pass + + +class Genre(Base): + __tablename__ = 'Genre' + + Genre: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Text_: Mapped[List['Text_']] = relationship('Text_', secondary='Text_Genre', back_populates='Genre_') + Werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Genre', back_populates='Genre_') + + +class Herausgeber(Base): + __tablename__ = 'Herausgeber' + + Name: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Herausgeber', back_populates='Herausgeber_') + + +class Pseudonym(Base): + __tablename__ = 'Pseudonym' + + Pseudonym: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='Pseudonym1') + + +class Sprache(Base): + __tablename__ = 'Sprache' + + Sprache: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Text_: Mapped[List['Text_']] = relationship('Text_', back_populates='Sprache1') + + +class Textform(Base): + __tablename__ = 'Textform' + + Textform: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Text_: Mapped[List['Text_']] = relationship('Text_', back_populates='Textform1') + + +class Titelbild(Base): + __tablename__ = 'Titelbild' + + Mimetype: Mapped[str] = mapped_column(Text) + Dateiname: Mapped[str] = mapped_column(Text) + Dateigroesse: Mapped[int] = mapped_column(Integer) + Breite: Mapped[int] = mapped_column(Integer) + Hoehe: Mapped[int] = mapped_column(Integer) + Bild: Mapped[bytes] = mapped_column(LargeBinary) + Thumbnail: Mapped[bytes] = mapped_column(LargeBinary) + sha256: Mapped[str] = mapped_column(Text, unique=True) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Titelbild1') + + +class Verlag(Base): + __tablename__ = 'Verlag' + + Verlag: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Reihe: Mapped[List['Reihe']] = relationship('Reihe', back_populates='Verlag1') + Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Verlag1') + + +class Werksform(Base): + __tablename__ = 'Werksform' + + Werksform: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + + Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Werksform1') + + +class Reihe(Base): + __tablename__ = 'Reihe' + + Titel: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + Verlag_: Mapped[Optional[str]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) + + Verlag1: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='Reihe') + Text_: Mapped[List['Text_']] = relationship('Text_', back_populates='Reihe1') + Werk: Mapped[List['Werk']] = relationship('Werk', back_populates='Reihe1') + + +class Text_(Base): + __tablename__ = 'Text' + + Titel: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + Untertitel: Mapped[Optional[str]] = mapped_column(Text) + Reihe_: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) + Textform_: Mapped[Optional[int]] = mapped_column('Textform', ForeignKey('Textform.ID')) + Sprache_: Mapped[Optional[int]] = mapped_column('Sprache', ForeignKey('Sprache.ID')) + + Genre_: Mapped[List['Genre']] = relationship('Genre', secondary='Text_Genre', back_populates='Text_') + Reihe1: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='Text_') + Sprache1: Mapped[Optional['Sprache']] = relationship('Sprache', back_populates='Text_') + Textform1: Mapped[Optional['Textform']] = relationship('Textform', back_populates='Text_') + Veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='Text2') + + +class Werk(Base): + __tablename__ = 'Werk' + + Titel: Mapped[str] = mapped_column(Text) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + Untertitel: Mapped[Optional[str]] = mapped_column(Text) + Werksform_: Mapped[Optional[int]] = mapped_column('Werksform', ForeignKey('Werksform.ID')) + Verlag_: Mapped[Optional[int]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) + Reihe_: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) + Reihennummer: Mapped[Optional[str]] = mapped_column(Text) + Erscheinungsdatum: Mapped[Optional[str]] = mapped_column(Text) + ISBN_13: Mapped[Optional[str]] = mapped_column(Text) + ISBN_10: Mapped[Optional[str]] = mapped_column(Text) + ISSN: Mapped[Optional[str]] = mapped_column(Text) + Preis: Mapped[Optional[str]] = mapped_column(Text) + Titelbild_: Mapped[Optional[int]] = mapped_column('Titelbild', ForeignKey('Titelbild.ID')) + Klappentext: Mapped[Optional[str]] = mapped_column(Text) + Anmerkungen: Mapped[Optional[str]] = mapped_column(Text) + + Genre_: Mapped[List['Genre']] = relationship('Genre', secondary='Werk_Genre', back_populates='Werk') + Herausgeber_: Mapped[List['Herausgeber']] = relationship('Herausgeber', secondary='Werk_Herausgeber', back_populates='Werk') + Reihe1: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='Werk') + Titelbild1: Mapped[Optional['Titelbild']] = relationship('Titelbild', back_populates='Werk') + Verlag1: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='Werk') + Werksform1: Mapped[Optional['Werksform']] = relationship('Werksform', back_populates='Werk') + Veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='Werk1') + + +t_Text_Genre = Table( + 'Text_Genre', Base.metadata, + Column('Text', ForeignKey('Text.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) +) + + +class Veroeffentlichung(Base): + __tablename__ = 'Veroeffentlichung' + + Text1: Mapped[int] = mapped_column('Text', ForeignKey('Text.ID')) + Werk_: Mapped[int] = mapped_column('Werk', ForeignKey('Werk.ID')) + Pseudonym_: Mapped[int] = mapped_column('Pseudonym', ForeignKey('Pseudonym.ID')) + ID: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True) + AltTitel: Mapped[Optional[str]] = mapped_column(Text) + AltUntertitel: Mapped[Optional[str]] = mapped_column(Text) + + Pseudonym1: Mapped['Pseudonym'] = relationship('Pseudonym', back_populates='Veroeffentlichung') + Text2: Mapped['Text_'] = relationship('Text_', back_populates='Veroeffentlichung') + Werk1: Mapped['Werk'] = relationship('Werk', back_populates='Veroeffentlichung') + + +t_Werk_Genre = Table( + 'Werk_Genre', Base.metadata, + Column('Werk', ForeignKey('Werk.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) +) + + +t_Werk_Herausgeber = Table( + 'Werk_Herausgeber', Base.metadata, + Column('Herausgeber', ForeignKey('Herausgeber.ID'), primary_key=True), + Column('Werk', ForeignKey('Werk.ID'), primary_key=True) +) diff --git a/utils/sqlacodegen_tables.py b/utils/sqlacodegen_tables.py index 2b4ccb8..542673b 100644 --- a/utils/sqlacodegen_tables.py +++ b/utils/sqlacodegen_tables.py @@ -1,3 +1,5 @@ +# File contains output of `sqlacodegen --generator tables sqlite:///path/to/the_works.sqlite` + from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, MetaData, Table, Text metadata = MetaData() From dfe728fdc8d017aee9c77cb81ac21670eba5f153 Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 21:29:52 +0200 Subject: [PATCH 07/24] added functionality that creates tables (from code generated by sqlacodegen) inside the databse if it is empty when opened --- the_works/database.py | 12 ++++- the_works/tables.py | 123 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 the_works/tables.py diff --git a/the_works/database.py b/the_works/database.py index c5bd12c..fd35f2b 100644 --- a/the_works/database.py +++ b/the_works/database.py @@ -5,4 +5,14 @@ db = SQLAlchemy() def init_db(app): db.init_app(app) with app.app_context(): - db.reflect() \ No newline at end of file + print(f"number of db tables is {len(db.metadata.tables)}") + + # populate an empty DB with fresh tables + #TODO: maybe add tables to metadata in any case since tables won't get overwritten + if not len(db.metadata.tables): + from the_works.tables import add_tables + add_tables(db.metadata) + db.metadata.create_all(db.engine) + print(f"and now number of db tables is {len(db.metadata.tables)}") + + db.reflect() diff --git a/the_works/tables.py b/the_works/tables.py new file mode 100644 index 0000000..c2ea897 --- /dev/null +++ b/the_works/tables.py @@ -0,0 +1,123 @@ +# File content is based on the output of `sqlacodegen --generator tables sqlite:///path/to/the_works.sqlite` + +from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, MetaData, Table, Text + +def add_tables(metadata): + t_Genre = Table( + 'Genre', metadata, + Column('ID', Integer, primary_key=True), + Column('Genre', Text, nullable=False) + ) + + t_Herausgeber = Table( + 'Herausgeber', metadata, + Column('ID', Integer, primary_key=True), + Column('Name', Text, nullable=False) + ) + + t_Pseudonym = Table( + 'Pseudonym', metadata, + Column('ID', Integer, primary_key=True), + Column('Pseudonym', Text, nullable=False) + ) + + t_Sprache = Table( + 'Sprache', metadata, + Column('ID', Integer, primary_key=True), + Column('Sprache', Text, nullable=False) + ) + + t_Textform = Table( + 'Textform', metadata, + Column('ID', Integer, primary_key=True), + Column('Textform', Text, nullable=False) + ) + + t_Titelbild = Table( + 'Titelbild', metadata, + Column('ID', Integer, primary_key=True), + Column('Mimetype', Text, nullable=False), + Column('Dateiname', Text, nullable=False), + Column('Dateigroesse', Integer, nullable=False), + Column('Breite', Integer, nullable=False), + Column('Hoehe', Integer, nullable=False), + Column('Bild', LargeBinary, nullable=False), + Column('Thumbnail', LargeBinary, nullable=False), + Column('sha256', Text, nullable=False, unique=True) + ) + + t_Verlag = Table( + 'Verlag', metadata, + Column('ID', Integer, primary_key=True), + Column('Verlag', Text, nullable=False) + ) + + t_Werksform = Table( + 'Werksform', metadata, + Column('ID', Integer, primary_key=True), + Column('Werksform', Text, nullable=False) + ) + + t_Reihe = Table( + 'Reihe', metadata, + Column('ID', Integer, primary_key=True), + Column('Titel', Text, nullable=False), + Column('Verlag', ForeignKey('Verlag.ID')) + ) + + t_Text = Table( + 'Text', metadata, + Column('ID', Integer, primary_key=True), + Column('Titel', Text, nullable=False), + Column('Untertitel', Text), + Column('Reihe', ForeignKey('Reihe.ID')), + Column('Textform', ForeignKey('Textform.ID')), + Column('Sprache', ForeignKey('Sprache.ID')) + ) + + t_Werk = Table( + 'Werk', metadata, + Column('ID', Integer, primary_key=True), + Column('Titel', Text, nullable=False), + Column('Untertitel', Text), + Column('Werksform', ForeignKey('Werksform.ID')), + Column('Verlag', ForeignKey('Verlag.ID')), + Column('Reihe', ForeignKey('Reihe.ID')), + Column('Reihennummer', Text), + Column('Erscheinungsdatum', Text), + Column('ISBN_13', Text), + Column('ISBN_10', Text), + Column('ISSN', Text), + Column('Preis', Text), + Column('Titelbild', ForeignKey('Titelbild.ID')), + Column('Klappentext', Text), + Column('Anmerkungen', Text) + ) + + t_Text_Genre = Table( + 'Text_Genre', metadata, + Column('Text', ForeignKey('Text.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) + ) + + t_Veroeffentlichung = Table( + 'Veroeffentlichung', metadata, + Column('ID', Integer, primary_key=True), + Column('Text', ForeignKey('Text.ID'), nullable=False), + Column('Werk', ForeignKey('Werk.ID'), nullable=False), + Column('AltTitel', Text), + Column('AltUntertitel', Text), + Column('Pseudonym', ForeignKey('Pseudonym.ID'), nullable=False) + ) + + t_Werk_Genre = Table( + 'Werk_Genre', metadata, + Column('Werk', ForeignKey('Werk.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) + ) + + t_Werk_Herausgeber = Table( + 'Werk_Herausgeber', metadata, + Column('Herausgeber', ForeignKey('Herausgeber.ID'), primary_key=True), + Column('Werk', ForeignKey('Werk.ID'), primary_key=True) + ) From a607b8c4f3c8e62d00dd99ae6ee5105b4cc581ac Mon Sep 17 00:00:00 2001 From: eclipse Date: Wed, 16 Jul 2025 21:32:34 +0200 Subject: [PATCH 08/24] first steps towards testing: added app fixture and first test function --- tests/conftest.py | 29 +++++++++++++++++++++++++++++ tests/unit/test_home.py | 3 +++ 2 files changed, 32 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/unit/test_home.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e1ffdf8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +import pytest +from the_works import create_app + +TEST_DATABASE_URI = "sqlite:///:memory:" + +@pytest.fixture() +def app(): + test_config = { + "ENV": "Testing", + "SQLALCHEMY_DATABASE_URI": TEST_DATABASE_URI, + "TESTING": True + } + app = create_app(test_config) + + # other setup can go here + + yield app + + # clean up / reset resources here + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def runner(app): + return app.test_cli_runner() \ No newline at end of file diff --git a/tests/unit/test_home.py b/tests/unit/test_home.py new file mode 100644 index 0000000..268d5a5 --- /dev/null +++ b/tests/unit/test_home.py @@ -0,0 +1,3 @@ +def test_request_home_startpage(client): + response = client.get("/") + assert response.data.startswith(b"\n") \ No newline at end of file From 93198254f639281d782bd5fd7e53962d1d9dcc3b Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 17 Jul 2025 09:45:23 +0200 Subject: [PATCH 09/24] improved handling of empty DB; "tables,py" now contains verbatim code from sqlacodegen, eliminating the need for code reformatting --- the_works/database.py | 23 +++++++++------ the_works/tables.py | 66 ++++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/the_works/database.py b/the_works/database.py index fd35f2b..fdd805c 100644 --- a/the_works/database.py +++ b/the_works/database.py @@ -5,14 +5,21 @@ db = SQLAlchemy() def init_db(app): db.init_app(app) with app.app_context(): - print(f"number of db tables is {len(db.metadata.tables)}") + # check if database is empty and if so, populate it with fresh tables + #TODO: does it make sense to try and create all tables by default? Existing tables wouldn't be overwritten but what about DB constraints ? + if not len(db.metadata.tables): + # import table classes from code that was generated via `sqlacodegen --generator tables sqlite:///the_works.sqlite > ./the_works/tables.py` in project root + import the_works.tables - # populate an empty DB with fresh tables - #TODO: maybe add tables to metadata in any case since tables won't get overwritten - if not len(db.metadata.tables): - from the_works.tables import add_tables - add_tables(db.metadata) + # filter the objects just imported for those of type sqlalchemy.Table + from sqlalchemy import Table + table_list = list(filter(lambda t: type(t) == Table, vars(the_works.tables).values())) + + # Table objects imported from sqlacodegen code are associated with a random MetaData() object, so we have to re-associate them with the DB's metadata + table_list = list(map(lambda t: t.to_metadata(db.metadata), table_list)) + + # create tables in DB ) db.metadata.create_all(db.engine) - print(f"and now number of db tables is {len(db.metadata.tables)}") - + + # generate declarative table objects by reflecting the DB db.reflect() diff --git a/the_works/tables.py b/the_works/tables.py index c2ea897..25ffcba 100644 --- a/the_works/tables.py +++ b/the_works/tables.py @@ -1,39 +1,41 @@ -# File content is based on the output of `sqlacodegen --generator tables sqlite:///path/to/the_works.sqlite` +# file created via `sqlacodegen --generator tables sqlite:///path/to/the_works.sqlite > tables.py` from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, MetaData, Table, Text -def add_tables(metadata): - t_Genre = Table( +metadata = MetaData() + + +t_Genre = Table( 'Genre', metadata, Column('ID', Integer, primary_key=True), Column('Genre', Text, nullable=False) - ) +) - t_Herausgeber = Table( +t_Herausgeber = Table( 'Herausgeber', metadata, Column('ID', Integer, primary_key=True), Column('Name', Text, nullable=False) - ) +) - t_Pseudonym = Table( +t_Pseudonym = Table( 'Pseudonym', metadata, Column('ID', Integer, primary_key=True), Column('Pseudonym', Text, nullable=False) - ) +) - t_Sprache = Table( +t_Sprache = Table( 'Sprache', metadata, Column('ID', Integer, primary_key=True), Column('Sprache', Text, nullable=False) - ) +) - t_Textform = Table( +t_Textform = Table( 'Textform', metadata, Column('ID', Integer, primary_key=True), Column('Textform', Text, nullable=False) - ) +) - t_Titelbild = Table( +t_Titelbild = Table( 'Titelbild', metadata, Column('ID', Integer, primary_key=True), Column('Mimetype', Text, nullable=False), @@ -44,28 +46,28 @@ def add_tables(metadata): Column('Bild', LargeBinary, nullable=False), Column('Thumbnail', LargeBinary, nullable=False), Column('sha256', Text, nullable=False, unique=True) - ) +) - t_Verlag = Table( +t_Verlag = Table( 'Verlag', metadata, Column('ID', Integer, primary_key=True), Column('Verlag', Text, nullable=False) - ) +) - t_Werksform = Table( +t_Werksform = Table( 'Werksform', metadata, Column('ID', Integer, primary_key=True), Column('Werksform', Text, nullable=False) - ) +) - t_Reihe = Table( +t_Reihe = Table( 'Reihe', metadata, Column('ID', Integer, primary_key=True), Column('Titel', Text, nullable=False), Column('Verlag', ForeignKey('Verlag.ID')) - ) +) - t_Text = Table( +t_Text = Table( 'Text', metadata, Column('ID', Integer, primary_key=True), Column('Titel', Text, nullable=False), @@ -73,9 +75,9 @@ def add_tables(metadata): Column('Reihe', ForeignKey('Reihe.ID')), Column('Textform', ForeignKey('Textform.ID')), Column('Sprache', ForeignKey('Sprache.ID')) - ) +) - t_Werk = Table( +t_Werk = Table( 'Werk', metadata, Column('ID', Integer, primary_key=True), Column('Titel', Text, nullable=False), @@ -92,15 +94,15 @@ def add_tables(metadata): Column('Titelbild', ForeignKey('Titelbild.ID')), Column('Klappentext', Text), Column('Anmerkungen', Text) - ) +) - t_Text_Genre = Table( +t_Text_Genre = Table( 'Text_Genre', metadata, Column('Text', ForeignKey('Text.ID'), primary_key=True), Column('Genre', ForeignKey('Genre.ID'), primary_key=True) - ) +) - t_Veroeffentlichung = Table( +t_Veroeffentlichung = Table( 'Veroeffentlichung', metadata, Column('ID', Integer, primary_key=True), Column('Text', ForeignKey('Text.ID'), nullable=False), @@ -108,16 +110,16 @@ def add_tables(metadata): Column('AltTitel', Text), Column('AltUntertitel', Text), Column('Pseudonym', ForeignKey('Pseudonym.ID'), nullable=False) - ) +) - t_Werk_Genre = Table( +t_Werk_Genre = Table( 'Werk_Genre', metadata, Column('Werk', ForeignKey('Werk.ID'), primary_key=True), Column('Genre', ForeignKey('Genre.ID'), primary_key=True) - ) +) - t_Werk_Herausgeber = Table( +t_Werk_Herausgeber = Table( 'Werk_Herausgeber', metadata, Column('Herausgeber', ForeignKey('Herausgeber.ID'), primary_key=True), Column('Werk', ForeignKey('Werk.ID'), primary_key=True) - ) +) From 569e08e0ac799a3fe793077f84e60df9c47bd58b Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 17 Jul 2025 11:08:22 +0200 Subject: [PATCH 10/24] extended inforation about code generated with sqlacodegen --- README.md | 27 +++++++++++++++++---------- the_works/tables.py | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ad25c63..1c99dfc 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ ## Configuration -The file `.flaskenv` contains the default configuration. Flask reads the file at startup and adds the contained key-value-pairs as environment variables to the runtime environment. When the Flask app object is being created in `__init__.py`, all environment variables that start with the prefix "FLASK_" are added to the app configuration. This is true for any prefixed environment variable, not just the ones from `.flaskenv`. +The file `.flaskenv` contains the default configuration. Flask reads the file at startup and adds its key-value-pairs to the runtime environment as environment variables. When the Flask app object is being created in `__init__.py`, all environment variables that start with the prefix "FLASK_" get added to the app configuration. -Configuration values can be overridden by passing another config file to Flask via the `-e` command line switch. It will be processed the same way as `.flaskenv`, which means that all keys must be prefixed with "FLASK_", and takes precedence over the default configuration. +This is true for any prefixed environment variable, not just the ones from `.flaskenv`. It is therefore possible to set additional config parameters by hand before running the app. Just make sure to prefix the variable name with "FLASK_". -Finally, you can override config settings while instantiating the Flask app through the factory. To do this, simply pass a dictionary with the (unprefixed) override settings as named parameter `config` to the `create_app()` method. Settings passed this way take precedence over the default configuration and an optional config file. +Configuration values from the runtime environment can be overridden by using Flask's `-e` command line switch to pass a second config file to the app. This file gets processed the same way as `.flaskenv`, which means that all its keys must be prefixed with "FLASK_". These vars take precedence over the default configuration. + +Finally, you can override config settings with Python during the Flask app's instantiation through the factory. To do this, simply pass a dictionary with (unprefixed) key-value-pairs to `create_app()` method as named parameter `config`. Settings passed this way take precedence over those from the default configuration and additional config files. @@ -35,6 +37,7 @@ python-dotenv Pillow pytest flask-debugtoolbar (optional) +sqlacodegen (optional; only ever used from the command line during development) ### CSS and Javascript @@ -50,6 +53,17 @@ some icons from heroicons.com ## Other useful stuff +### Generate SQLAlchemy code from an existing database + +Right now the_works reflects an existing database in order to infer the underlying data models for SQLAlchemy. Of course, this only works if all the tables already exist in the database. If the_works is run with an empty database (this happens when running tests, for example), the app will create fresh tables in the database. The necessary information about the tables can be generated from an existing database with the help of [sqlacodegen](https://pypi.org/project/sqlacodegen/). Just run this command inside the project's root directory: + +`sqlacodegen --generator tables sqlite:///path/to/good/db.sqlite > ./the_works/tables.py` + +The tool sqlacodegen can also generate Python code declaring the data models directly. This would make the use of reflection obsolete. The command would be: + +`sqlacodegen --generator declarative sqlite:///path/to/good/db.sqlite > outputfile.py` + + ### Export database schema Method 1: `sqlite3 the_works.sqlite .schema > outputfile.sql` @@ -61,13 +75,6 @@ Method 2: Open DB in SQLitebrowser and use File -> Export -> Database to SQL fil * overwrite old schema (DROP TABLE, then CREATE TABLE) -### Generate declarative SQLAlchemy code - -Right now the_works reflects an existing database in order to infer the underlying data models for SQLAlchemy. If I wanted to declare the data models directly instead of using reflection, I would need declarative code. This code can be generated from an existing database with the help of [sqlacodegen](https://pypi.org/project/sqlacodegen/): - -`sqlacodegen sqlite:///path/to/db.sqlite > outputfile.py` - - ### Generate `requirements.txt` I use [pipreqs](https://pypi.org/project/pipreqs/) to generate the file `requirements.txt`. The package scans all source files for import statements and uses those to extract all required Pip packages. \ No newline at end of file diff --git a/the_works/tables.py b/the_works/tables.py index 25ffcba..eddc474 100644 --- a/the_works/tables.py +++ b/the_works/tables.py @@ -1,4 +1,4 @@ -# file created via `sqlacodegen --generator tables sqlite:///path/to/the_works.sqlite > tables.py` +# this is the verbatim output of `sqlacodegen --generator tables sqlite:///path/to/the_works.sqlite`, run inside project root from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, MetaData, Table, Text From a64f7efa0727c5822ba36b320cf844525142d4a7 Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 17 Jul 2025 21:21:21 +0200 Subject: [PATCH 11/24] removed some debug code, improved comments --- the_works/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/the_works/__init__.py b/the_works/__init__.py index e1c6b24..daea846 100644 --- a/the_works/__init__.py +++ b/the_works/__init__.py @@ -1,8 +1,10 @@ -import os -from dotenv import load_dotenv from flask import Flask from the_works.database import init_db -from flask_debugtoolbar import DebugToolbarExtension + +# this import is not strictly necessary but it forces pipreqs-to include dotenv when generating `requirements.txt` +import dotenv + +#from flask_debugtoolbar import DebugToolbarExtension def create_app(config=None): app = Flask(__name__) @@ -12,7 +14,7 @@ def create_app(config=None): # some #DEBUG configuration # toolbar = DebugToolbarExtension(app) #DEBUG - app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False #DEBUG +# app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False #DEBUG # use config from function parameter if present if config: @@ -47,8 +49,8 @@ def create_app(config=None): return app -# helper function to print formatted file size from https://stackoverflow.com/a/1094933 -def sizeof_fmt(num, suffix="B"): +# helper function to print formatted file size; [source](https://stackoverflow.com/a/1094933) +def sizeof_fmt(num: int, suffix: str = "B") -> str: if type(num) == "String": num = int(num) for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"): From 4517d0f55725c76de55ad477ab3fad2788032ec6 Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 17 Jul 2025 21:23:18 +0200 Subject: [PATCH 12/24] added introductory section; improved Dependencies section --- README.md | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1c99dfc..24a7843 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,18 @@ -# The Works +# the_works – a publication management tool for writers + +This software project + +* supports managing texts and publications with multiple languages, pseudonyms, genres, publishers, series, editions, and more +* supports storage of cover images +* focuses on ease-of-use, safety, and speed +* written and field-tested by an actual writer + +the_works also is + +* based on common technologies like Python, SQL, and HTML/JavaScript +* underlying frameworks are Flask and SQLAlchemy +* includes test coverage (using pytest) + ## Configuration @@ -21,7 +35,7 @@ Available commands: * `run`: Serve app (don't use for production). -* `shell`: start a shell within the app context (I can i.e. import specific table models and test the data structures returned by ORM methods like select()) +* `shell`: start a shell within the app context (I can i.e. import specific table models and test ORM data structures) * @@ -31,18 +45,25 @@ Available commands: ### Python Packages -flask -flask-sqlalchemy -python-dotenv -Pillow -pytest -flask-debugtoolbar (optional) -sqlacodegen (optional; only ever used from the command line during development) +Required pip packages -### CSS and Javascript +* flask +* flask-sqlalchemy +* python-dotenv +* Pillow +* pytest -PicoCSS (regular version) + SwitchColorMode.js (from Yohn's fork) -DataTables.[js|css] +Optional pip packages + +* flask-debugtoolbar (optional) +* sqlacodegen (optional; only used from the command line during development) + +### CSS and Javascript resources + +* any regular (not classless) stylesheet from [PicoCSS](https://picocss.com) for general styling +* `SwitchColorMode.js` from [Yohn's fork of PicoCSS](https://yohn.github.io/PicoCSS/) to enable color mode switching +* [DataTables](https://datatables.net/) (JS and CSS components) to enable ordering and filtering result tables + * DataTables requires [jQuery](https://jquery.com/), which can either be bundled with DataTables itself or installed as a separate resource ### Icons From 4b951ebf7da4406ef061ea7d524279d9274e90a9 Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 17 Jul 2025 23:55:34 +0200 Subject: [PATCH 13/24] started migrating ORM code from DB reflection to declarative mapping --- the_works/database.py | 11 +- the_works/models.py | 270 ++++++++++++++++++--------- the_works/old_models.py | 121 ++++++++++++ the_works/views/home.py | 6 +- the_works/views/text.py | 2 +- the_works/views/veroeffentlichung.py | 2 +- the_works/views/werk.py | 2 +- 7 files changed, 315 insertions(+), 99 deletions(-) create mode 100644 the_works/old_models.py diff --git a/the_works/database.py b/the_works/database.py index fdd805c..dd04987 100644 --- a/the_works/database.py +++ b/the_works/database.py @@ -1,10 +1,18 @@ from flask_sqlalchemy import SQLAlchemy +from the_works.models import Base -db = SQLAlchemy() +# Instantiate the SQLAlchemy around the database schema's declarative mapping +db = SQLAlchemy(model_class=Base) def init_db(app): + # initialize the Flask app with the flask_sqlalchemy extension db.init_app(app) + + # create nonexistent tables in DB with app.app_context(): + db.create_all() + +""" # check if database is empty and if so, populate it with fresh tables #TODO: does it make sense to try and create all tables by default? Existing tables wouldn't be overwritten but what about DB constraints ? if not len(db.metadata.tables): @@ -23,3 +31,4 @@ def init_db(app): # generate declarative table objects by reflecting the DB db.reflect() + """ \ No newline at end of file diff --git a/the_works/models.py b/the_works/models.py index a188895..3387762 100644 --- a/the_works/models.py +++ b/the_works/models.py @@ -1,93 +1,89 @@ -from the_works.database import db -from sqlalchemy.orm import relationship -from sqlalchemy.ext.associationproxy import association_proxy +# based on output of `sqlacodegen --generator declarative sqlite:///path/to/the_works.sqlite` + +from typing import List, Optional + +from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, Table, Text, types +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + from flask import url_for import sys -# add method to sqlalchemy.orm.decl_api.Model -def _asdict(self): - d = {} - for col in self.__table__.c: - if type(col.type) == db.types.BLOB: - d[col.key] = "Blob (NULL)" if self.__getattribute__(col.key) == None else f"Blob ({sys.getsizeof(self.__getattribute__(col.key))} Bytes)" - else: - value = str(self.__getattribute__(col.key)) - d[col.key] = value[:50] + '...' if len(value) > 50 else value - return d -db.Model._asdict = _asdict -# override repr() method from sqlalchemy.orm.decl_api.Model -def __repr__(self): - return f"{type(self).__name__}({str(self._asdict())})" -db.Model.__repr__ = __repr__ +class Base(DeclarativeBase): + def _asdict(self): + d = {} + for col in self.__table__.c: + if type(col.type) == types.BLOB: + d[col.key] = "Blob (NULL)" if self.__getattribute__(col.key) == None else f"Blob ({sys.getsizeof(self.__getattribute__(col.key))} Bytes)" + else: + value = str(self.__getattribute__(col.key)) + d[col.key] = value[:50] + '...' if len(value) > 50 else value + return d -class Text(db.Model): - __table__ = db.Model.metadata.tables['Text'] - reihe = relationship("Reihe", back_populates="text") - textform = relationship("Textform", back_populates="text") - sprache = relationship("Sprache", back_populates="text") - veroeffentlichung = relationship("Veroeffentlichung", back_populates="text") - text_genre = relationship("Text_Genre", back_populates="text", cascade="save-update, merge, delete, delete-orphan") - genres = association_proxy("text_genre", "Genre") + def __repr__(self): + return f"{type(self).__name__}({str(self._asdict())})" -class Werk(db.Model): - __table__ = db.Model.metadata.tables['Werk'] - reihe = relationship("Reihe", back_populates="werk") - verlag = relationship("Verlag", back_populates="werk") - werksform = relationship("Werksform", back_populates="werk") - veroeffentlichung = relationship("Veroeffentlichung", back_populates="werk") - werk_genre = relationship("Werk_Genre", back_populates="werk", cascade="save-update, merge, delete, delete-orphan") -# titelbild = relationship("Titelbild", back_populates="werk", cascade="all, delete-orphan", single_parent=True) - titelbild = relationship("Titelbild", back_populates="werk") - genres = association_proxy("werk_genre", "Genre") - werk_herausgeber = relationship("Werk_Herausgeber", back_populates="werk", cascade="save-update, merge, delete, delete-orphan") - herausgeber = association_proxy("werk_herausgeber", "Herausgeber") -class Veroeffentlichung(db.Model): - __table__ = db.Model.metadata.tables['Veroeffentlichung'] - text = relationship("Text", back_populates="veroeffentlichung") - werk = relationship("Werk", back_populates="veroeffentlichung") - pseudonym = relationship("Pseudonym", back_populates="veroeffentlichung") +class Genre(Base): + __tablename__ = 'Genre' -class Reihe(db.Model): - __table__ = db.Model.metadata.tables['Reihe'] - text = relationship("Text", back_populates="reihe") - werk = relationship("Werk", back_populates="reihe") - verlag = relationship("Verlag", back_populates="reihe") + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Genre: Mapped[str] -class Verlag(db.Model): - __table__ = db.Model.metadata.tables['Verlag'] - werk = relationship("Werk", back_populates="verlag") - reihe = relationship("Reihe", back_populates="verlag") + text: Mapped[List['Text_']] = relationship('Text_', secondary='Text_Genre', back_populates='genre') + werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Genre', back_populates='genre') -class Sprache(db.Model): - __table__ = db.Model.metadata.tables['Sprache'] - text = relationship("Text", back_populates="sprache") -class Textform(db.Model): - __table__ = db.Model.metadata.tables['Textform'] - text = relationship("Text", back_populates="textform") +class Herausgeber(Base): + __tablename__ = 'Herausgeber' -class Werksform(db.Model): - __table__ = db.Model.metadata.tables['Werksform'] - werk = relationship("Werk", back_populates="werksform") + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Name: Mapped[str] -class Genre(db.Model): - __table__ = db.Model.metadata.tables['Genre'] - text_genre = relationship("Text_Genre", back_populates="genre") - werk_genre = relationship("Werk_Genre", back_populates="genre") + werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Herausgeber', back_populates='herausgeber') -class Pseudonym(db.Model): - __table__ = db.Model.metadata.tables['Pseudonym'] - veroeffentlichung = relationship("Veroeffentlichung", back_populates="pseudonym") -class Herausgeber(db.Model): - __table__ = db.Model.metadata.tables['Herausgeber'] - werk_herausgeber = relationship("Werk_Herausgeber", back_populates="herausgeber") +class Pseudonym(Base): + __tablename__ = 'Pseudonym' -class Titelbild(db.Model): - __table__ = db.Model.metadata.tables['Titelbild'] - werk = relationship("Werk", back_populates="titelbild") + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Pseudonym: Mapped[str] + + veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='pseudonym') + + +class Sprache(Base): + __tablename__ = 'Sprache' + + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Sprache: Mapped[str] + + text: Mapped[List['Text_']] = relationship('Text_', back_populates='sprache') + + +class Textform(Base): + __tablename__ = 'Textform' + + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Textform: Mapped[str] + + text: Mapped[List['Text_']] = relationship('Text_', back_populates='textform') + + +class Titelbild(Base): + __tablename__ = 'Titelbild' + + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Mimetype: Mapped[str] + Dateiname: Mapped[str] + Dateigroesse: Mapped[int] + Breite: Mapped[int] + Hoehe: Mapped[int] + Bild: Mapped[bytes] + Thumbnail: Mapped[bytes] + sha256: Mapped[str] = mapped_column(Text, unique=True) + + werk: Mapped[List['Werk']] = relationship('Werk', back_populates='titelbild') def _asdict_with_urls(self): tb = self._asdict() @@ -95,27 +91,117 @@ class Titelbild(db.Model): tb["Thumbnail"] = url_for("titelbild.thumbnail", id=self.ID) return tb -class Text_Genre(db.Model): - __table__ = db.Model.metadata.tables['Text_Genre'] - text = relationship("Text", back_populates="text_genre") - genre = relationship("Genre", back_populates="text_genre") - def __init__(self, genre: int): - self.Genre = genre +class Verlag(Base): + __tablename__ = 'Verlag' -class Werk_Genre(db.Model): - __table__ = db.Model.metadata.tables['Werk_Genre'] - werk = relationship("Werk", back_populates="werk_genre") - genre = relationship("Genre", back_populates="werk_genre") + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Verlag: Mapped[str] - def __init__(self, genre: int): - self.Genre = genre + reihe: Mapped[List['Reihe']] = relationship('Reihe', back_populates='verlag') + werk: Mapped[List['Werk']] = relationship('Werk', back_populates='verlag') -class Werk_Herausgeber(db.Model): - __table__ = db.Model.metadata.tables['Werk_Herausgeber'] - werk = relationship("Werk", back_populates="werk_herausgeber") - herausgeber = relationship("Herausgeber", back_populates="werk_herausgeber") - def __init__(self, hrsg: int): - self.Herausgeber = hrsg +class Werksform(Base): + __tablename__ = 'Werksform' + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Werksform: Mapped[str] + + werk: Mapped[List['Werk']] = relationship('Werk', back_populates='werksform') + + +class Reihe(Base): + __tablename__ = 'Reihe' + + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Titel: Mapped[str] + Verlag: Mapped[Optional[str]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) + + verlag: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='reihe') + text: Mapped[List['Text_']] = relationship('Text_', back_populates='reihe') + werk: Mapped[List['Werk']] = relationship('Werk', back_populates='reihe') + + +class Text_(Base): + __tablename__ = 'Text' + + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Titel: Mapped[str] + Untertitel: Mapped[Optional[str]] + Reihe: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) + Textform: Mapped[Optional[int]] = mapped_column('Textform', ForeignKey('Textform.ID')) + Sprache: Mapped[Optional[int]] = mapped_column('Sprache', ForeignKey('Sprache.ID')) + + genre: Mapped[List['Genre']] = relationship('Genre', secondary='Text_Genre', back_populates='text') + reihe: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='text') + sprache: Mapped[Optional['Sprache']] = relationship('Sprache', back_populates='text') + textform: Mapped[Optional['Textform']] = relationship('Textform', back_populates='text') + veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='text') + + +class Werk(Base): + __tablename__ = 'Werk' + + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Titel: Mapped[str] + Untertitel: Mapped[Optional[str]] + Werksform: Mapped[Optional[int]] = mapped_column('Werksform', ForeignKey('Werksform.ID')) + Verlag: Mapped[Optional[int]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) + Reihe: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) + Reihennummer: Mapped[Optional[str]] + Erscheinungsdatum: Mapped[Optional[str]] + ISBN_13: Mapped[Optional[str]] + ISBN_10: Mapped[Optional[str]] + ISSN: Mapped[Optional[str]] + Preis: Mapped[Optional[str]] + Titelbild: Mapped[Optional[int]] = mapped_column('Titelbild', ForeignKey('Titelbild.ID')) + Klappentext: Mapped[Optional[str]] + Anmerkungen: Mapped[Optional[str]] + + genre: Mapped[List['Genre']] = relationship('Genre', secondary='Werk_Genre', back_populates='werk') + herausgeber: Mapped[List['Herausgeber']] = relationship('Herausgeber', secondary='Werk_Herausgeber', back_populates='werk') + reihe: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='werk') + titelbild: Mapped[Optional['Titelbild']] = relationship('Titelbild', back_populates='werk') + verlag: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='werk') + werksform: Mapped[Optional['Werksform']] = relationship('Werksform', back_populates='werk') + veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='werk') + + +t_Text_Genre = Table( + 'Text_Genre', + Base.metadata, + Column('Text', ForeignKey('Text.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) +) + + +class Veroeffentlichung(Base): + __tablename__ = 'Veroeffentlichung' + + ID: Mapped[int] = mapped_column(Integer, primary_key=True) + Text: Mapped[int] = mapped_column('Text', ForeignKey('Text.ID')) + Werk: Mapped[int] = mapped_column('Werk', ForeignKey('Werk.ID')) + Pseudonym: Mapped[int] = mapped_column('Pseudonym', ForeignKey('Pseudonym.ID')) + AltTitel: Mapped[Optional[str]] + AltUntertitel: Mapped[Optional[str]] + + pseudonym: Mapped['Pseudonym'] = relationship('Pseudonym', back_populates='veroeffentlichung') + text: Mapped['Text_'] = relationship('Text_', back_populates='veroeffentlichung') + werk: Mapped['Werk'] = relationship('Werk', back_populates='veroeffentlichung') + + +t_Werk_Genre = Table( + 'Werk_Genre', + Base.metadata, + Column('Werk', ForeignKey('Werk.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) +) + + +t_Werk_Herausgeber = Table( + 'Werk_Herausgeber', + Base.metadata, + Column('Herausgeber', ForeignKey('Herausgeber.ID'), primary_key=True), + Column('Werk', ForeignKey('Werk.ID'), primary_key=True) +) diff --git a/the_works/old_models.py b/the_works/old_models.py new file mode 100644 index 0000000..a188895 --- /dev/null +++ b/the_works/old_models.py @@ -0,0 +1,121 @@ +from the_works.database import db +from sqlalchemy.orm import relationship +from sqlalchemy.ext.associationproxy import association_proxy +from flask import url_for +import sys + +# add method to sqlalchemy.orm.decl_api.Model +def _asdict(self): + d = {} + for col in self.__table__.c: + if type(col.type) == db.types.BLOB: + d[col.key] = "Blob (NULL)" if self.__getattribute__(col.key) == None else f"Blob ({sys.getsizeof(self.__getattribute__(col.key))} Bytes)" + else: + value = str(self.__getattribute__(col.key)) + d[col.key] = value[:50] + '...' if len(value) > 50 else value + return d +db.Model._asdict = _asdict + +# override repr() method from sqlalchemy.orm.decl_api.Model +def __repr__(self): + return f"{type(self).__name__}({str(self._asdict())})" +db.Model.__repr__ = __repr__ + +class Text(db.Model): + __table__ = db.Model.metadata.tables['Text'] + reihe = relationship("Reihe", back_populates="text") + textform = relationship("Textform", back_populates="text") + sprache = relationship("Sprache", back_populates="text") + veroeffentlichung = relationship("Veroeffentlichung", back_populates="text") + text_genre = relationship("Text_Genre", back_populates="text", cascade="save-update, merge, delete, delete-orphan") + genres = association_proxy("text_genre", "Genre") + +class Werk(db.Model): + __table__ = db.Model.metadata.tables['Werk'] + reihe = relationship("Reihe", back_populates="werk") + verlag = relationship("Verlag", back_populates="werk") + werksform = relationship("Werksform", back_populates="werk") + veroeffentlichung = relationship("Veroeffentlichung", back_populates="werk") + werk_genre = relationship("Werk_Genre", back_populates="werk", cascade="save-update, merge, delete, delete-orphan") +# titelbild = relationship("Titelbild", back_populates="werk", cascade="all, delete-orphan", single_parent=True) + titelbild = relationship("Titelbild", back_populates="werk") + genres = association_proxy("werk_genre", "Genre") + werk_herausgeber = relationship("Werk_Herausgeber", back_populates="werk", cascade="save-update, merge, delete, delete-orphan") + herausgeber = association_proxy("werk_herausgeber", "Herausgeber") + +class Veroeffentlichung(db.Model): + __table__ = db.Model.metadata.tables['Veroeffentlichung'] + text = relationship("Text", back_populates="veroeffentlichung") + werk = relationship("Werk", back_populates="veroeffentlichung") + pseudonym = relationship("Pseudonym", back_populates="veroeffentlichung") + +class Reihe(db.Model): + __table__ = db.Model.metadata.tables['Reihe'] + text = relationship("Text", back_populates="reihe") + werk = relationship("Werk", back_populates="reihe") + verlag = relationship("Verlag", back_populates="reihe") + +class Verlag(db.Model): + __table__ = db.Model.metadata.tables['Verlag'] + werk = relationship("Werk", back_populates="verlag") + reihe = relationship("Reihe", back_populates="verlag") + +class Sprache(db.Model): + __table__ = db.Model.metadata.tables['Sprache'] + text = relationship("Text", back_populates="sprache") + +class Textform(db.Model): + __table__ = db.Model.metadata.tables['Textform'] + text = relationship("Text", back_populates="textform") + +class Werksform(db.Model): + __table__ = db.Model.metadata.tables['Werksform'] + werk = relationship("Werk", back_populates="werksform") + +class Genre(db.Model): + __table__ = db.Model.metadata.tables['Genre'] + text_genre = relationship("Text_Genre", back_populates="genre") + werk_genre = relationship("Werk_Genre", back_populates="genre") + +class Pseudonym(db.Model): + __table__ = db.Model.metadata.tables['Pseudonym'] + veroeffentlichung = relationship("Veroeffentlichung", back_populates="pseudonym") + +class Herausgeber(db.Model): + __table__ = db.Model.metadata.tables['Herausgeber'] + werk_herausgeber = relationship("Werk_Herausgeber", back_populates="herausgeber") + +class Titelbild(db.Model): + __table__ = db.Model.metadata.tables['Titelbild'] + werk = relationship("Werk", back_populates="titelbild") + + def _asdict_with_urls(self): + tb = self._asdict() + tb["Bild"] = url_for("titelbild.image", id=self.ID) + tb["Thumbnail"] = url_for("titelbild.thumbnail", id=self.ID) + return tb + +class Text_Genre(db.Model): + __table__ = db.Model.metadata.tables['Text_Genre'] + text = relationship("Text", back_populates="text_genre") + genre = relationship("Genre", back_populates="text_genre") + + def __init__(self, genre: int): + self.Genre = genre + +class Werk_Genre(db.Model): + __table__ = db.Model.metadata.tables['Werk_Genre'] + werk = relationship("Werk", back_populates="werk_genre") + genre = relationship("Genre", back_populates="werk_genre") + + def __init__(self, genre: int): + self.Genre = genre + +class Werk_Herausgeber(db.Model): + __table__ = db.Model.metadata.tables['Werk_Herausgeber'] + werk = relationship("Werk", back_populates="werk_herausgeber") + herausgeber = relationship("Herausgeber", back_populates="werk_herausgeber") + + def __init__(self, hrsg: int): + self.Herausgeber = hrsg + diff --git a/the_works/views/home.py b/the_works/views/home.py index 67ccd71..5b2e584 100644 --- a/the_works/views/home.py +++ b/the_works/views/home.py @@ -6,12 +6,12 @@ import inspect bp = Blueprint("home", __name__) -# prepare list of DB table classes to be searched by search_all() +# prepare list of ORM classes to be searched by search_all() tables = [] for name, obj in inspect.getmembers(the_works.models): - if "_" not in name and inspect.isclass(obj): + if inspect.isclass(obj) and issubclass(obj, the_works.models.Base) and obj.__name__ != "Base": tables.append(obj) -#print(tables) #DEBUG +print(f"tables is {tables}") #DEBUG @bp.route("/") def startpage(): diff --git a/the_works/views/text.py b/the_works/views/text.py index eee7f2d..643be2b 100644 --- a/the_works/views/text.py +++ b/the_works/views/text.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for from sqlalchemy import select, insert, update, delete from the_works.database import db -from the_works.models import Text, Reihe, Sprache, Textform, Text_Genre, Genre +from the_works.models import Text_ as Text, Reihe, Sprache, Textform, t_Text_Genre, Genre bp = Blueprint("text", __name__) diff --git a/the_works/views/veroeffentlichung.py b/the_works/views/veroeffentlichung.py index 7ffcc9f..186363e 100644 --- a/the_works/views/veroeffentlichung.py +++ b/the_works/views/veroeffentlichung.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for from sqlalchemy import select, insert, update, delete from the_works.database import db -from the_works.models import Veroeffentlichung, Text, Werk, Werksform, Pseudonym +from the_works.models import Veroeffentlichung, Text_ as Text, Werk, Werksform, Pseudonym bp = Blueprint("veroeffentlichung", __name__) diff --git a/the_works/views/werk.py b/the_works/views/werk.py index 57bb1fb..2b48b85 100644 --- a/the_works/views/werk.py +++ b/the_works/views/werk.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for from sqlalchemy import select, insert, update, delete from the_works.database import db -from the_works.models import Werk, Reihe, Verlag, Werksform, Werk_Genre, Genre, Werk_Herausgeber, Herausgeber, Titelbild +from the_works.models import Werk, Reihe, Verlag, Werksform, t_Werk_Genre, Genre, t_Werk_Herausgeber, Herausgeber, Titelbild from the_works.views import titelbild as tb bp = Blueprint("werk", __name__) From 270085e4243c75502f60718d17fae880aeacdf29 Mon Sep 17 00:00:00 2001 From: eclipse Date: Fri, 18 Jul 2025 10:31:24 +0200 Subject: [PATCH 14/24] simplified declarative mapping code, eliminated use of datatype classes like sqlalchemy.Text, thus eliminated a naming conflict between two classes named "Text" --- the_works/models.py | 58 ++++++++++++++-------------- the_works/views/text.py | 2 +- the_works/views/veroeffentlichung.py | 2 +- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/the_works/models.py b/the_works/models.py index 3387762..b914839 100644 --- a/the_works/models.py +++ b/the_works/models.py @@ -1,8 +1,8 @@ -# based on output of `sqlacodegen --generator declarative sqlite:///path/to/the_works.sqlite` +# code is based on output from `sqlacodegen --generator declarative sqlite:///path/to/the_works.sqlite` from typing import List, Optional -from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, Table, Text, types +from sqlalchemy import Column, ForeignKey, Table, types from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from flask import url_for @@ -27,17 +27,17 @@ class Base(DeclarativeBase): class Genre(Base): __tablename__ = 'Genre' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Genre: Mapped[str] - text: Mapped[List['Text_']] = relationship('Text_', secondary='Text_Genre', back_populates='genre') + text: Mapped[List['Text']] = relationship('Text', secondary='Text_Genre', back_populates='genre') werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Genre', back_populates='genre') class Herausgeber(Base): __tablename__ = 'Herausgeber' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Name: Mapped[str] werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Herausgeber', back_populates='herausgeber') @@ -46,7 +46,7 @@ class Herausgeber(Base): class Pseudonym(Base): __tablename__ = 'Pseudonym' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Pseudonym: Mapped[str] veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='pseudonym') @@ -55,25 +55,25 @@ class Pseudonym(Base): class Sprache(Base): __tablename__ = 'Sprache' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Sprache: Mapped[str] - text: Mapped[List['Text_']] = relationship('Text_', back_populates='sprache') + text: Mapped[List['Text']] = relationship('Text', back_populates='sprache') class Textform(Base): __tablename__ = 'Textform' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Textform: Mapped[str] - text: Mapped[List['Text_']] = relationship('Text_', back_populates='textform') + text: Mapped[List['Text']] = relationship('Text', back_populates='textform') class Titelbild(Base): __tablename__ = 'Titelbild' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Mimetype: Mapped[str] Dateiname: Mapped[str] Dateigroesse: Mapped[int] @@ -81,7 +81,7 @@ class Titelbild(Base): Hoehe: Mapped[int] Bild: Mapped[bytes] Thumbnail: Mapped[bytes] - sha256: Mapped[str] = mapped_column(Text, unique=True) + sha256: Mapped[str] = mapped_column(unique=True) werk: Mapped[List['Werk']] = relationship('Werk', back_populates='titelbild') @@ -95,7 +95,7 @@ class Titelbild(Base): class Verlag(Base): __tablename__ = 'Verlag' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Verlag: Mapped[str] reihe: Mapped[List['Reihe']] = relationship('Reihe', back_populates='verlag') @@ -105,7 +105,7 @@ class Verlag(Base): class Werksform(Base): __tablename__ = 'Werksform' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Werksform: Mapped[str] werk: Mapped[List['Werk']] = relationship('Werk', back_populates='werksform') @@ -114,19 +114,19 @@ class Werksform(Base): class Reihe(Base): __tablename__ = 'Reihe' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Titel: Mapped[str] Verlag: Mapped[Optional[str]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) verlag: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='reihe') - text: Mapped[List['Text_']] = relationship('Text_', back_populates='reihe') + text: Mapped[List['Text']] = relationship('Text', back_populates='reihe') werk: Mapped[List['Werk']] = relationship('Werk', back_populates='reihe') -class Text_(Base): +class Text(Base): __tablename__ = 'Text' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Titel: Mapped[str] Untertitel: Mapped[Optional[str]] Reihe: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) @@ -143,7 +143,7 @@ class Text_(Base): class Werk(Base): __tablename__ = 'Werk' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Titel: Mapped[str] Untertitel: Mapped[Optional[str]] Werksform: Mapped[Optional[int]] = mapped_column('Werksform', ForeignKey('Werksform.ID')) @@ -168,18 +168,10 @@ class Werk(Base): veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='werk') -t_Text_Genre = Table( - 'Text_Genre', - Base.metadata, - Column('Text', ForeignKey('Text.ID'), primary_key=True), - Column('Genre', ForeignKey('Genre.ID'), primary_key=True) -) - - class Veroeffentlichung(Base): __tablename__ = 'Veroeffentlichung' - ID: Mapped[int] = mapped_column(Integer, primary_key=True) + ID: Mapped[int] = mapped_column(primary_key=True) Text: Mapped[int] = mapped_column('Text', ForeignKey('Text.ID')) Werk: Mapped[int] = mapped_column('Werk', ForeignKey('Werk.ID')) Pseudonym: Mapped[int] = mapped_column('Pseudonym', ForeignKey('Pseudonym.ID')) @@ -187,10 +179,18 @@ class Veroeffentlichung(Base): AltUntertitel: Mapped[Optional[str]] pseudonym: Mapped['Pseudonym'] = relationship('Pseudonym', back_populates='veroeffentlichung') - text: Mapped['Text_'] = relationship('Text_', back_populates='veroeffentlichung') + text: Mapped['Text'] = relationship('Text', back_populates='veroeffentlichung') werk: Mapped['Werk'] = relationship('Werk', back_populates='veroeffentlichung') +t_Text_Genre = Table( + 'Text_Genre', + Base.metadata, + Column('Text', ForeignKey('Text.ID'), primary_key=True), + Column('Genre', ForeignKey('Genre.ID'), primary_key=True) +) + + t_Werk_Genre = Table( 'Werk_Genre', Base.metadata, diff --git a/the_works/views/text.py b/the_works/views/text.py index 643be2b..54f1437 100644 --- a/the_works/views/text.py +++ b/the_works/views/text.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for from sqlalchemy import select, insert, update, delete from the_works.database import db -from the_works.models import Text_ as Text, Reihe, Sprache, Textform, t_Text_Genre, Genre +from the_works.models import Text, Reihe, Sprache, Textform, t_Text_Genre, Genre bp = Blueprint("text", __name__) diff --git a/the_works/views/veroeffentlichung.py b/the_works/views/veroeffentlichung.py index 186363e..7ffcc9f 100644 --- a/the_works/views/veroeffentlichung.py +++ b/the_works/views/veroeffentlichung.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for from sqlalchemy import select, insert, update, delete from the_works.database import db -from the_works.models import Veroeffentlichung, Text_ as Text, Werk, Werksform, Pseudonym +from the_works.models import Veroeffentlichung, Text, Werk, Werksform, Pseudonym bp = Blueprint("veroeffentlichung", __name__) From 091c977f037d3ae23770a3c5b6ac837f77520c2f Mon Sep 17 00:00:00 2001 From: eclipse Date: Fri, 18 Jul 2025 23:35:05 +0200 Subject: [PATCH 15/24] fixed a bug --- the_works/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/the_works/__init__.py b/the_works/__init__.py index daea846..0a6eb31 100644 --- a/the_works/__init__.py +++ b/the_works/__init__.py @@ -50,8 +50,8 @@ def create_app(config=None): # helper function to print formatted file size; [source](https://stackoverflow.com/a/1094933) -def sizeof_fmt(num: int, suffix: str = "B") -> str: - if type(num) == "String": +def sizeof_fmt(num: int | str, suffix: str = "B") -> str: + if type(num) == str: num = int(num) for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"): if abs(num) < 1024.0: From 3dd08fb4c4f04ef34abda729c2fd9589bfbaefde Mon Sep 17 00:00:00 2001 From: eclipse Date: Sat, 19 Jul 2025 00:26:27 +0200 Subject: [PATCH 16/24] streamlined declarative mapping code, (re)added assoc. proxies, fixed bugs caused by the switch to declarative --- the_works/models.py | 172 +++++++++++++++++++++++----------------- the_works/views/text.py | 33 ++++---- the_works/views/werk.py | 45 ++++++----- 3 files changed, 136 insertions(+), 114 deletions(-) diff --git a/the_works/models.py b/the_works/models.py index b914839..27d6de5 100644 --- a/the_works/models.py +++ b/the_works/models.py @@ -1,27 +1,28 @@ -# code is based on output from `sqlacodegen --generator declarative sqlite:///path/to/the_works.sqlite` +# code is built upon output from sqlacodegen -from typing import List, Optional - -from sqlalchemy import Column, ForeignKey, Table, types -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - -from flask import url_for import sys +from typing import List, Optional +from sqlalchemy import ForeignKey, types +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy +from flask import url_for class Base(DeclarativeBase): - def _asdict(self): + def asdict(self) -> dict: d = {} for col in self.__table__.c: - if type(col.type) == types.BLOB: - d[col.key] = "Blob (NULL)" if self.__getattribute__(col.key) == None else f"Blob ({sys.getsizeof(self.__getattribute__(col.key))} Bytes)" + if isinstance(col.type, types.BLOB): + d[col.key] = "Blob (NULL)" if self.__getattribute__(col.key) is None else f"Blob ({sys.getsizeof(self.__getattribute__(col.key))} Bytes)" else: - value = str(self.__getattribute__(col.key)) - d[col.key] = value[:50] + '...' if len(value) > 50 else value + if isinstance(value := self.__getattribute__(col.key), str) and len(value) > 50: + d[col.key] = value[:48] + '...' + else: + d[col.key] = value return d - def __repr__(self): - return f"{type(self).__name__}({str(self._asdict())})" + def __repr__(self) -> str: + return f"{type(self).__name__}({str(self.asdict())})" class Genre(Base): @@ -30,8 +31,8 @@ class Genre(Base): ID: Mapped[int] = mapped_column(primary_key=True) Genre: Mapped[str] - text: Mapped[List['Text']] = relationship('Text', secondary='Text_Genre', back_populates='genre') - werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Genre', back_populates='genre') + texte: Mapped[List['Text_Genre']] = relationship(back_populates='genre') + werke: Mapped[List['Werk_Genre']] = relationship(back_populates='genre') class Herausgeber(Base): @@ -40,7 +41,7 @@ class Herausgeber(Base): ID: Mapped[int] = mapped_column(primary_key=True) Name: Mapped[str] - werk: Mapped[List['Werk']] = relationship('Werk', secondary='Werk_Herausgeber', back_populates='herausgeber') + werke: Mapped[List['Werk_Herausgeber']] = relationship(back_populates='herausgeber') class Pseudonym(Base): @@ -49,7 +50,7 @@ class Pseudonym(Base): ID: Mapped[int] = mapped_column(primary_key=True) Pseudonym: Mapped[str] - veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='pseudonym') + veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship(back_populates='pseudonym') class Sprache(Base): @@ -58,7 +59,7 @@ class Sprache(Base): ID: Mapped[int] = mapped_column(primary_key=True) Sprache: Mapped[str] - text: Mapped[List['Text']] = relationship('Text', back_populates='sprache') + text: Mapped[List['Text']] = relationship(back_populates='sprache') class Textform(Base): @@ -67,7 +68,7 @@ class Textform(Base): ID: Mapped[int] = mapped_column(primary_key=True) Textform: Mapped[str] - text: Mapped[List['Text']] = relationship('Text', back_populates='textform') + text: Mapped[List['Text']] = relationship(back_populates='textform') class Titelbild(Base): @@ -83,10 +84,10 @@ class Titelbild(Base): Thumbnail: Mapped[bytes] sha256: Mapped[str] = mapped_column(unique=True) - werk: Mapped[List['Werk']] = relationship('Werk', back_populates='titelbild') + werk: Mapped[List['Werk']] = relationship(back_populates='titelbild') - def _asdict_with_urls(self): - tb = self._asdict() + def asdict_with_urls(self): + tb = self.asdict() tb["Bild"] = url_for("titelbild.image", id=self.ID) tb["Thumbnail"] = url_for("titelbild.thumbnail", id=self.ID) return tb @@ -98,8 +99,8 @@ class Verlag(Base): ID: Mapped[int] = mapped_column(primary_key=True) Verlag: Mapped[str] - reihe: Mapped[List['Reihe']] = relationship('Reihe', back_populates='verlag') - werk: Mapped[List['Werk']] = relationship('Werk', back_populates='verlag') + reihe: Mapped[List['Reihe']] = relationship(back_populates='verlag') + werk: Mapped[List['Werk']] = relationship(back_populates='verlag') class Werksform(Base): @@ -108,7 +109,7 @@ class Werksform(Base): ID: Mapped[int] = mapped_column(primary_key=True) Werksform: Mapped[str] - werk: Mapped[List['Werk']] = relationship('Werk', back_populates='werksform') + werk: Mapped[List['Werk']] = relationship(back_populates='werksform') class Reihe(Base): @@ -116,92 +117,117 @@ class Reihe(Base): ID: Mapped[int] = mapped_column(primary_key=True) Titel: Mapped[str] - Verlag: Mapped[Optional[str]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) - verlag: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='reihe') - text: Mapped[List['Text']] = relationship('Text', back_populates='reihe') - werk: Mapped[List['Werk']] = relationship('Werk', back_populates='reihe') + Verlag: Mapped[Optional[str]] = mapped_column(ForeignKey('Verlag.ID')) + verlag: Mapped['Verlag'] = relationship(back_populates='reihe') + + text: Mapped[List['Text']] = relationship(back_populates='reihe') + werk: Mapped[List['Werk']] = relationship(back_populates='reihe') class Text(Base): __tablename__ = 'Text' + # regular columns ID: Mapped[int] = mapped_column(primary_key=True) Titel: Mapped[str] Untertitel: Mapped[Optional[str]] - Reihe: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) - Textform: Mapped[Optional[int]] = mapped_column('Textform', ForeignKey('Textform.ID')) - Sprache: Mapped[Optional[int]] = mapped_column('Sprache', ForeignKey('Sprache.ID')) - genre: Mapped[List['Genre']] = relationship('Genre', secondary='Text_Genre', back_populates='text') - reihe: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='text') - sprache: Mapped[Optional['Sprache']] = relationship('Sprache', back_populates='text') - textform: Mapped[Optional['Textform']] = relationship('Textform', back_populates='text') - veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='text') + # many-to-one + Reihe: Mapped[Optional[int]] = mapped_column(ForeignKey('Reihe.ID')) + reihe: Mapped[Optional["Reihe"]] = relationship(back_populates="text") + Sprache: Mapped[int] = mapped_column(ForeignKey('Sprache.ID')) + sprache: Mapped['Sprache'] = relationship(back_populates='text') + Textform: Mapped[int] = mapped_column(ForeignKey('Textform.ID')) + textform: Mapped['Textform'] = relationship(back_populates='text') + + # one-to-many + veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship(back_populates='text') + + # many-to-many + genres: Mapped[List['Text_Genre']] = relationship(back_populates='text', cascade="all, delete-orphan") + genre_ids: AssociationProxy[List["Genre"]] = association_proxy("genres", "Genre", creator=lambda genre_id: Text_Genre(Genre=genre_id)) class Werk(Base): __tablename__ = 'Werk' + # regular columns ID: Mapped[int] = mapped_column(primary_key=True) Titel: Mapped[str] Untertitel: Mapped[Optional[str]] - Werksform: Mapped[Optional[int]] = mapped_column('Werksform', ForeignKey('Werksform.ID')) - Verlag: Mapped[Optional[int]] = mapped_column('Verlag', ForeignKey('Verlag.ID')) - Reihe: Mapped[Optional[int]] = mapped_column('Reihe', ForeignKey('Reihe.ID')) Reihennummer: Mapped[Optional[str]] Erscheinungsdatum: Mapped[Optional[str]] ISBN_13: Mapped[Optional[str]] ISBN_10: Mapped[Optional[str]] ISSN: Mapped[Optional[str]] Preis: Mapped[Optional[str]] - Titelbild: Mapped[Optional[int]] = mapped_column('Titelbild', ForeignKey('Titelbild.ID')) Klappentext: Mapped[Optional[str]] Anmerkungen: Mapped[Optional[str]] - genre: Mapped[List['Genre']] = relationship('Genre', secondary='Werk_Genre', back_populates='werk') - herausgeber: Mapped[List['Herausgeber']] = relationship('Herausgeber', secondary='Werk_Herausgeber', back_populates='werk') - reihe: Mapped[Optional['Reihe']] = relationship('Reihe', back_populates='werk') - titelbild: Mapped[Optional['Titelbild']] = relationship('Titelbild', back_populates='werk') - verlag: Mapped[Optional['Verlag']] = relationship('Verlag', back_populates='werk') - werksform: Mapped[Optional['Werksform']] = relationship('Werksform', back_populates='werk') - veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship('Veroeffentlichung', back_populates='werk') + # many-to-one + Reihe: Mapped[Optional[int]] = mapped_column(ForeignKey('Reihe.ID')) + reihe: Mapped[Optional['Reihe']] = relationship(back_populates='werk') + Titelbild: Mapped[Optional[int]] = mapped_column(ForeignKey('Titelbild.ID')) + titelbild: Mapped[Optional['Titelbild']] = relationship(back_populates='werk') + Verlag: Mapped[Optional[int]] = mapped_column(ForeignKey('Verlag.ID')) + verlag: Mapped[Optional['Verlag']] = relationship(back_populates='werk') + Werksform: Mapped[int] = mapped_column(ForeignKey('Werksform.ID')) + werksform: Mapped['Werksform'] = relationship(back_populates='werk') + + # one-to-many + veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship(back_populates='werk') + + # many-to-many + genres: Mapped[List['Werk_Genre']] = relationship(back_populates='werk', cascade='all, delete-orphan') + genre_ids: AssociationProxy[List["Genre"]] = association_proxy("genres", "Genre", creator=lambda genre_id: Werk_Genre(Genre=genre_id)) + herausgeber: Mapped[List['Werk_Herausgeber']] = relationship(back_populates='werk', cascade="all, delete-orphan") + herausgeber_ids: AssociationProxy[List['Herausgeber']] = association_proxy("herausgeber", "Herausgeber", creator=lambda hrsg_id: Werk_Herausgeber(Herausgeber=hrsg_id)) class Veroeffentlichung(Base): __tablename__ = 'Veroeffentlichung' + # regular columns ID: Mapped[int] = mapped_column(primary_key=True) - Text: Mapped[int] = mapped_column('Text', ForeignKey('Text.ID')) - Werk: Mapped[int] = mapped_column('Werk', ForeignKey('Werk.ID')) - Pseudonym: Mapped[int] = mapped_column('Pseudonym', ForeignKey('Pseudonym.ID')) AltTitel: Mapped[Optional[str]] AltUntertitel: Mapped[Optional[str]] - pseudonym: Mapped['Pseudonym'] = relationship('Pseudonym', back_populates='veroeffentlichung') - text: Mapped['Text'] = relationship('Text', back_populates='veroeffentlichung') - werk: Mapped['Werk'] = relationship('Werk', back_populates='veroeffentlichung') + # many-to-one + Pseudonym: Mapped[int] = mapped_column(ForeignKey('Pseudonym.ID')) + pseudonym: Mapped['Pseudonym'] = relationship(back_populates='veroeffentlichung') + Text: Mapped[int] = mapped_column(ForeignKey('Text.ID')) + text: Mapped['Text'] = relationship(back_populates='veroeffentlichung') + Werk: Mapped[int] = mapped_column(ForeignKey('Werk.ID')) + werk: Mapped['Werk'] = relationship(back_populates='veroeffentlichung') -t_Text_Genre = Table( - 'Text_Genre', - Base.metadata, - Column('Text', ForeignKey('Text.ID'), primary_key=True), - Column('Genre', ForeignKey('Genre.ID'), primary_key=True) -) +class Text_Genre(Base): + __tablename__ = 'Text_Genre' + + Text: Mapped[int] = mapped_column(ForeignKey("Text.ID"), primary_key=True) + Genre: Mapped[int] = mapped_column(ForeignKey("Genre.ID"), primary_key=True) + + text: Mapped['Text'] = relationship(back_populates="genres") + genre: Mapped['Genre'] = relationship(back_populates="texte") -t_Werk_Genre = Table( - 'Werk_Genre', - Base.metadata, - Column('Werk', ForeignKey('Werk.ID'), primary_key=True), - Column('Genre', ForeignKey('Genre.ID'), primary_key=True) -) +class Werk_Genre(Base): + __tablename__ = 'Werk_Genre' + + Werk: Mapped[int] = mapped_column(ForeignKey('Werk.ID'), primary_key=True) + Genre: Mapped[int] = mapped_column(ForeignKey("Genre.ID"), primary_key=True) + + werk: Mapped['Werk'] = relationship(back_populates="genres") + genre: Mapped['Genre'] = relationship(back_populates="werke") -t_Werk_Herausgeber = Table( - 'Werk_Herausgeber', - Base.metadata, - Column('Herausgeber', ForeignKey('Herausgeber.ID'), primary_key=True), - Column('Werk', ForeignKey('Werk.ID'), primary_key=True) -) +class Werk_Herausgeber(Base): + __tablename__ = 'Werk_Herausgeber' + + Werk: Mapped[int] = mapped_column(ForeignKey('Werk.ID'), primary_key=True) + Herausgeber: Mapped[int] = mapped_column(ForeignKey("Herausgeber.ID"), primary_key=True) + + werk: Mapped['Werk'] = relationship(back_populates="herausgeber") + herausgeber: Mapped['Herausgeber'] = relationship(back_populates="werke") + diff --git a/the_works/views/text.py b/the_works/views/text.py index 54f1437..c36e5c2 100644 --- a/the_works/views/text.py +++ b/the_works/views/text.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db -from the_works.models import Text, Reihe, Sprache, Textform, t_Text_Genre, Genre +from the_works.models import Text, Reihe, Sprache, Textform, Genre bp = Blueprint("text", __name__) @@ -23,8 +23,7 @@ def all(): "tf_id": row.Text.Textform, "Sprache": row.Sprache.Sprache, "s_id": row.Text.Sprache, - "Genre_list": [tg.genre.Genre for tg in row.Text.text_genre], - "g_id_list": row.Text.genres + "Genre_list": [tg.genre.Genre for tg in row.Text.genres] }) return render_template("views/text.html", texte=texte) @@ -35,15 +34,11 @@ def read(id): return render_template("views/text_detail.html", text={"ID": 0}, reihen=db.session.scalars(select(Reihe)), textformen=db.session.scalars(select(Textform)), sprachen=db.session.scalars(select(Sprache)), genres=db.session.scalars(select(Genre))) # all other ids -> update existing entry t = db.session.get(Text, id) - text = { - "ID": t.ID, - "Titel": t.Titel, - "Untertitel": t.Untertitel or "", - "Reihe": t.Reihe or "", - "Textform": t.Textform, - "Sprache": t.Sprache, - "Genres": t.genres - } + if not t: + raise ValueError(f"Text with ID {id} not found") + text = t.asdict() + text["Genres"] = t.genre_ids + return render_template("views/text_detail.html", text=text, reihen=db.session.scalars(select(Reihe)), textformen=db.session.scalars(select(Textform)), sprachen=db.session.scalars(select(Sprache)), genres=db.session.scalars(select(Genre))) @bp.route("/text/create", methods=["POST"]) @@ -56,7 +51,7 @@ def create(): Sprache = request.form["form_Sprache"] ) for g in request.form.getlist("form_Genres"): - text.genres.append(g) + text.genre_ids.append(int(g)) db.session.add(text) db.session.commit() flash("Eintrag erfolgreich hinzugefügt") @@ -75,11 +70,11 @@ def update(id): text.Sprache = request.form["form_Sprache"] # update genre list by removing genres not in form selection and adding selected ones not currently in list - form_set = set(map(lambda g: int(g), request.form.getlist("form_Genre"))) - for g in set(text.genres) - form_set: - text.genres.remove(g) - for g in form_set - set(text.genres): - text.genres.append(g) + form_set = set(map(int, request.form.getlist("form_Genre"))) + for g in set(text.genre_ids) - form_set: + text.genre_ids.remove(g) + for g in form_set - set(text.genre_ids): + text.genre_ids.append(g) # commit changes db.session.commit() diff --git a/the_works/views/werk.py b/the_works/views/werk.py index 2b48b85..fb151db 100644 --- a/the_works/views/werk.py +++ b/the_works/views/werk.py @@ -1,8 +1,7 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db -from the_works.models import Werk, Reihe, Verlag, Werksform, t_Werk_Genre, Genre, t_Werk_Herausgeber, Herausgeber, Titelbild -from the_works.views import titelbild as tb +from the_works.models import Werk, Reihe, Verlag, Werksform, Genre, Herausgeber, Titelbild bp = Blueprint("werk", __name__) @@ -29,11 +28,11 @@ def all(): "ISSN": row.Werk.ISSN or "", "Preis": row.Werk.Preis or "", # "Titelbild": url_for("titelbild.thumbnail", id=row.Werk.Titelbild) if row.Werk.Titelbild else "", - "Titelbild": db.session.get(Titelbild, row.Werk.Titelbild)._asdict_with_urls() if row.Werk.Titelbild else "", + "Titelbild": db.session.get(Titelbild, row.Werk.Titelbild).asdict_with_urls() if row.Werk.Titelbild else "", "Klappentext": row.Werk.Klappentext or "", "Anmerkungen": row.Werk.Anmerkungen or "", - "Herausgeber_list": [wh.herausgeber.Name for wh in row.Werk.werk_herausgeber], - "Genre_list": [wg.genre.Genre for wg in row.Werk.werk_genre], + "Herausgeber_list": [wh.herausgeber.Name for wh in row.Werk.herausgeber], + "Genre_list": [wg.genre.Genre for wg in row.Werk.genres], }) return render_template("views/werk.html", werke=werke) @@ -41,17 +40,19 @@ def all(): @bp.route("/werk/read/") def read(id): # prepare Titelbilder as dict including URLs for thumbnail and full pic - titelbilder = map(lambda t: t._asdict_with_urls(), db.session.scalars(select(Titelbild))) + titelbilder = map(lambda t: t.asdict_with_urls(), db.session.scalars(select(Titelbild))) # id of zero -> return empty data if id == 0: return render_template("views/werk_detail.html", werk={"ID": 0, "Erscheinungsdatum": ""}, reihen=db.session.scalars(select(Reihe)), verlage=db.session.scalars(select(Verlag)), werksformen=db.session.scalars(select(Werksform)), genres=db.session.scalars(select(Genre)), hrsg=db.session.scalars(select(Herausgeber)), titelbilder=titelbilder) # all other ids -> read existing entry from DB and return as dict - werk = db.session.get(Werk, id) - if not werk: + w = db.session.get(Werk, id) + if not w: raise ValueError(f"Werk with ID {id} not found") - werk = werk._asdict() + werk = w.asdict() + werk["Genres"] = w.genre_ids + werk["Herausgeber"] = w.herausgeber_ids return render_template("views/werk_detail.html", werk=werk, reihen=db.session.scalars(select(Reihe)), verlage=db.session.scalars(select(Verlag)), werksformen=db.session.scalars(select(Werksform)), genres=db.session.scalars(select(Genre)), hrsg=db.session.scalars(select(Herausgeber)), titelbilder=titelbilder) @@ -75,9 +76,9 @@ def create(): Anmerkungen = request.form["form_Anmerkungen"] or None ) for g in request.form.getlist("form_Genre"): - werk.genres.append(g) + werk.genre_ids.append(g) for h in request.form.getlist("form_Herausgeber"): - werk.herausgeber.append(h) + werk.herausgeber_ids.append(h) db.session.add(werk) db.session.commit() flash("Eintrag erfolgreich hinzugefügt") @@ -106,18 +107,18 @@ def update(id): werk.Anmerkungen = request.form["form_Anmerkungen"] or None # update associated values: Genre - form_set = set(map(lambda g: int(g), request.form.getlist("form_Genre"))) - for g in set(werk.genres) - form_set: - werk.genres.remove(g) - for g in form_set - set(werk.genres): - werk.genres.append(g) + form_set = set(map(int, request.form.getlist("form_Genre"))) + for g in set(werk.genre_ids) - form_set: + werk.genre_ids.remove(g) + for g in form_set - set(werk.genre_ids): + werk.genre_ids.append(g) # update associated values: Herausgeber - form_set = set(map(lambda h: int(h), request.form.getlist("form_Herausgeber"))) - for h in set(werk.herausgeber) - form_set: - werk.herausgeber.remove(h) - for h in form_set - set(werk.herausgeber): - werk.herausgeber.append(h) + form_set = set(map(int, request.form.getlist("form_Herausgeber"))) + for h in set(werk.herausgeber_ids) - form_set: + werk.herausgeber_ids.remove(h) + for h in form_set - set(werk.herausgeber_ids): + werk.herausgeber_ids.append(h) # commit changes db.session.commit() From 67e13ffacd46fb5d85855d34aa5f6696de3c2c1d Mon Sep 17 00:00:00 2001 From: eclipse Date: Sat, 19 Jul 2025 00:28:21 +0200 Subject: [PATCH 17/24] changed names of previously protected functions --- the_works/views/home.py | 4 ++-- the_works/views/titelbild.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/the_works/views/home.py b/the_works/views/home.py index 5b2e584..3a6f070 100644 --- a/the_works/views/home.py +++ b/the_works/views/home.py @@ -40,11 +40,11 @@ def search_all(): continue if matchCase: if s in row[0].__getattribute__(column): - hits.append(row[0]._asdict()) + hits.append(row[0].asdict()) break else: if s.lower() in row[0].__getattribute__(column).lower(): - hits.append(row[0]._asdict()) + hits.append(row[0].asdict()) break if hits != []: result[table.__table__.fullname] = hits diff --git a/the_works/views/titelbild.py b/the_works/views/titelbild.py index db378d9..98e6636 100644 --- a/the_works/views/titelbild.py +++ b/the_works/views/titelbild.py @@ -14,7 +14,7 @@ bp = Blueprint("titelbild", __name__) @bp.route("/titelbild") @bp.route("/titelbild/all") def all(): - return render_template("views/titelbild.html", titelbilder=map(lambda t: t._asdict_with_urls(), db.session.scalars(select(Titelbild)))) + return render_template("views/titelbild.html", titelbilder=map(lambda t: t.asdict_with_urls(), db.session.scalars(select(Titelbild)))) @bp.route("/titelbild/image/") From 33dc5e14b58b3c378135c8612d0fabbd591e5866 Mon Sep 17 00:00:00 2001 From: eclipse Date: Sat, 19 Jul 2025 00:44:46 +0200 Subject: [PATCH 18/24] linted code --- the_works/__init__.py | 7 ++++--- the_works/database.py | 25 ++----------------------- the_works/views/genre.py | 2 +- the_works/views/herausgeber.py | 2 +- the_works/views/pseudonym.py | 2 +- the_works/views/reihe.py | 2 +- the_works/views/sprache.py | 2 +- the_works/views/textform.py | 2 +- the_works/views/verlag.py | 2 +- the_works/views/veroeffentlichung.py | 2 +- the_works/views/werksform.py | 2 +- 11 files changed, 15 insertions(+), 35 deletions(-) diff --git a/the_works/__init__.py b/the_works/__init__.py index 0a6eb31..88fb91f 100644 --- a/the_works/__init__.py +++ b/the_works/__init__.py @@ -1,9 +1,11 @@ from flask import Flask -from the_works.database import init_db # this import is not strictly necessary but it forces pipreqs-to include dotenv when generating `requirements.txt` import dotenv +from the_works.database import init_db +from the_works.views import home, text, werk, verlag, sprache, textform, werksform, genre, pseudonym, reihe, herausgeber, veroeffentlichung, titelbild + #from flask_debugtoolbar import DebugToolbarExtension def create_app(config=None): @@ -28,7 +30,6 @@ def create_app(config=None): init_db(app) # register blueprints - from the_works.views import home, text, werk, verlag, sprache, textform, werksform, genre, pseudonym, reihe, herausgeber, veroeffentlichung, titelbild app.register_blueprint(genre.bp) app.register_blueprint(herausgeber.bp) app.register_blueprint(home.bp) @@ -51,7 +52,7 @@ def create_app(config=None): # helper function to print formatted file size; [source](https://stackoverflow.com/a/1094933) def sizeof_fmt(num: int | str, suffix: str = "B") -> str: - if type(num) == str: + if isinstance(num, str): num = int(num) for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"): if abs(num) < 1024.0: diff --git a/the_works/database.py b/the_works/database.py index dd04987..2c1fdf1 100644 --- a/the_works/database.py +++ b/the_works/database.py @@ -1,34 +1,13 @@ from flask_sqlalchemy import SQLAlchemy from the_works.models import Base -# Instantiate the SQLAlchemy around the database schema's declarative mapping +# Instantiate the SQLAlchemy around the database schema's declarative mapping db = SQLAlchemy(model_class=Base) def init_db(app): - # initialize the Flask app with the flask_sqlalchemy extension + # initialize the Flask app with the flask_sqlalchemy extension db.init_app(app) # create nonexistent tables in DB with app.app_context(): db.create_all() - -""" - # check if database is empty and if so, populate it with fresh tables - #TODO: does it make sense to try and create all tables by default? Existing tables wouldn't be overwritten but what about DB constraints ? - if not len(db.metadata.tables): - # import table classes from code that was generated via `sqlacodegen --generator tables sqlite:///the_works.sqlite > ./the_works/tables.py` in project root - import the_works.tables - - # filter the objects just imported for those of type sqlalchemy.Table - from sqlalchemy import Table - table_list = list(filter(lambda t: type(t) == Table, vars(the_works.tables).values())) - - # Table objects imported from sqlacodegen code are associated with a random MetaData() object, so we have to re-associate them with the DB's metadata - table_list = list(map(lambda t: t.to_metadata(db.metadata), table_list)) - - # create tables in DB ) - db.metadata.create_all(db.engine) - - # generate declarative table objects by reflecting the DB - db.reflect() - """ \ No newline at end of file diff --git a/the_works/views/genre.py b/the_works/views/genre.py index d813f92..7f6eaec 100644 --- a/the_works/views/genre.py +++ b/the_works/views/genre.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Genre diff --git a/the_works/views/herausgeber.py b/the_works/views/herausgeber.py index 9ac1f22..c4132c0 100644 --- a/the_works/views/herausgeber.py +++ b/the_works/views/herausgeber.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Herausgeber diff --git a/the_works/views/pseudonym.py b/the_works/views/pseudonym.py index 4326373..48e724b 100644 --- a/the_works/views/pseudonym.py +++ b/the_works/views/pseudonym.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Pseudonym diff --git a/the_works/views/reihe.py b/the_works/views/reihe.py index 9de8788..48cebad 100644 --- a/the_works/views/reihe.py +++ b/the_works/views/reihe.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Reihe, Verlag diff --git a/the_works/views/sprache.py b/the_works/views/sprache.py index 0e0a30f..2190460 100644 --- a/the_works/views/sprache.py +++ b/the_works/views/sprache.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Sprache diff --git a/the_works/views/textform.py b/the_works/views/textform.py index 6cbd446..1f9e05e 100644 --- a/the_works/views/textform.py +++ b/the_works/views/textform.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Textform diff --git a/the_works/views/verlag.py b/the_works/views/verlag.py index ae9cb57..f7389fa 100644 --- a/the_works/views/verlag.py +++ b/the_works/views/verlag.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Verlag diff --git a/the_works/views/veroeffentlichung.py b/the_works/views/veroeffentlichung.py index 7ffcc9f..79b561b 100644 --- a/the_works/views/veroeffentlichung.py +++ b/the_works/views/veroeffentlichung.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Veroeffentlichung, Text, Werk, Werksform, Pseudonym diff --git a/the_works/views/werksform.py b/the_works/views/werksform.py index 0a86304..41af10f 100644 --- a/the_works/views/werksform.py +++ b/the_works/views/werksform.py @@ -1,5 +1,5 @@ from flask import Blueprint, render_template, request, redirect, flash, url_for -from sqlalchemy import select, insert, update, delete +from sqlalchemy import select from the_works.database import db from the_works.models import Werksform From 0cd7d1280ebcc806f9e2f5a0fa914b4f0ac1877c Mon Sep 17 00:00:00 2001 From: eclipse Date: Sat, 19 Jul 2025 15:06:32 +0200 Subject: [PATCH 19/24] linted some code --- the_works/__init__.py | 6 +++--- the_works/models.py | 2 +- the_works/views/genre.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/the_works/__init__.py b/the_works/__init__.py index 88fb91f..9347c99 100644 --- a/the_works/__init__.py +++ b/the_works/__init__.py @@ -1,7 +1,7 @@ from flask import Flask # this import is not strictly necessary but it forces pipreqs-to include dotenv when generating `requirements.txt` -import dotenv +import dotenv from the_works.database import init_db from the_works.views import home, text, werk, verlag, sprache, textform, werksform, genre, pseudonym, reihe, herausgeber, veroeffentlichung, titelbild @@ -20,7 +20,7 @@ def create_app(config=None): # use config from function parameter if present if config: - app.config.update(config) + app.config.update(config) # some #DEBUG output print(f"Current Environment: {app.config['ENV'] if 'ENV' in app.config.keys() else 'ENV is not set'}") #DEBUG @@ -46,7 +46,7 @@ def create_app(config=None): # register helper function app.jinja_env.globals.update(sizeof_fmt=sizeof_fmt) - + return app diff --git a/the_works/models.py b/the_works/models.py index 27d6de5..abb8405 100644 --- a/the_works/models.py +++ b/the_works/models.py @@ -1,4 +1,4 @@ -# code is built upon output from sqlacodegen +# code is built upon output from sqlacodegen import sys from typing import List, Optional diff --git a/the_works/views/genre.py b/the_works/views/genre.py index 7f6eaec..0272ee6 100644 --- a/the_works/views/genre.py +++ b/the_works/views/genre.py @@ -24,7 +24,7 @@ def update(id): db.session.commit() flash("Eintrag erfolgreich geändert") return redirect(url_for("genre.all"), code=303) - + @bp.route("/genre/delete/") def delete(id): genre = db.session.get(Genre, id) From 9bbb646c519bf6545e81b8d6ed212d29c7d34e12 Mon Sep 17 00:00:00 2001 From: eclipse Date: Tue, 22 Jul 2025 13:25:02 +0200 Subject: [PATCH 20/24] added several uniqueness constraints --- the_works/models.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/the_works/models.py b/the_works/models.py index abb8405..59cd9a6 100644 --- a/the_works/models.py +++ b/the_works/models.py @@ -29,7 +29,7 @@ class Genre(Base): __tablename__ = 'Genre' ID: Mapped[int] = mapped_column(primary_key=True) - Genre: Mapped[str] + Genre: Mapped[str] = mapped_column(unique=True) texte: Mapped[List['Text_Genre']] = relationship(back_populates='genre') werke: Mapped[List['Werk_Genre']] = relationship(back_populates='genre') @@ -39,7 +39,7 @@ class Herausgeber(Base): __tablename__ = 'Herausgeber' ID: Mapped[int] = mapped_column(primary_key=True) - Name: Mapped[str] + Name: Mapped[str] = mapped_column(unique=True) werke: Mapped[List['Werk_Herausgeber']] = relationship(back_populates='herausgeber') @@ -48,7 +48,7 @@ class Pseudonym(Base): __tablename__ = 'Pseudonym' ID: Mapped[int] = mapped_column(primary_key=True) - Pseudonym: Mapped[str] + Pseudonym: Mapped[str] = mapped_column(unique=True) veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship(back_populates='pseudonym') @@ -57,7 +57,7 @@ class Sprache(Base): __tablename__ = 'Sprache' ID: Mapped[int] = mapped_column(primary_key=True) - Sprache: Mapped[str] + Sprache: Mapped[str] = mapped_column(unique=True) text: Mapped[List['Text']] = relationship(back_populates='sprache') @@ -66,7 +66,7 @@ class Textform(Base): __tablename__ = 'Textform' ID: Mapped[int] = mapped_column(primary_key=True) - Textform: Mapped[str] + Textform: Mapped[str] = mapped_column(unique=True) text: Mapped[List['Text']] = relationship(back_populates='textform') @@ -97,7 +97,7 @@ class Verlag(Base): __tablename__ = 'Verlag' ID: Mapped[int] = mapped_column(primary_key=True) - Verlag: Mapped[str] + Verlag: Mapped[str] = mapped_column(unique=True) reihe: Mapped[List['Reihe']] = relationship(back_populates='verlag') werk: Mapped[List['Werk']] = relationship(back_populates='verlag') @@ -107,7 +107,7 @@ class Werksform(Base): __tablename__ = 'Werksform' ID: Mapped[int] = mapped_column(primary_key=True) - Werksform: Mapped[str] + Werksform: Mapped[str] = mapped_column(unique=True) werk: Mapped[List['Werk']] = relationship(back_populates='werksform') From 5058ba133abb39276553ae57f1f038bf42870aba Mon Sep 17 00:00:00 2001 From: eclipse Date: Tue, 22 Jul 2025 13:26:46 +0200 Subject: [PATCH 21/24] added safeguard against bad form data --- the_works/views/genre.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/the_works/views/genre.py b/the_works/views/genre.py index 0272ee6..bea47e9 100644 --- a/the_works/views/genre.py +++ b/the_works/views/genre.py @@ -12,7 +12,7 @@ def all(): @bp.route("/genre/create", methods=["POST"]) def create(): - db.session.add(Genre(Genre = request.form["form_Genre"])) + db.session.add(Genre(Genre = request.form.get("form_Genre", default=None))) db.session.commit() flash("Eintrag erfolgreich hinzugefügt") return redirect(url_for("genre.all"), code=303) @@ -20,7 +20,7 @@ def create(): @bp.route("/genre/update/", methods=["POST"]) def update(id): genre = db.session.get(Genre, id) - genre.Genre = request.form["form_Genre"] + genre.Genre = request.form.get("form_Genre", default=None) db.session.commit() flash("Eintrag erfolgreich geändert") return redirect(url_for("genre.all"), code=303) From 0522c5660efcdf905b0dfcc560f02d61be895f61 Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 24 Jul 2025 10:35:04 +0200 Subject: [PATCH 22/24] models now validate that core fields are non-empty --- the_works/models.py | 54 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/the_works/models.py b/the_works/models.py index 59cd9a6..1ce5b17 100644 --- a/the_works/models.py +++ b/the_works/models.py @@ -1,9 +1,7 @@ -# code is built upon output from sqlacodegen - import sys from typing import List, Optional from sqlalchemy import ForeignKey, types -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, validates from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy from flask import url_for @@ -22,7 +20,12 @@ class Base(DeclarativeBase): return d def __repr__(self) -> str: - return f"{type(self).__name__}({str(self.asdict())})" + return f"{type(self).__name__}({str(self.asdict())})" + + def validate_not_empty(self, value): + if not value: + raise ValueError("value can't be empty") + return value class Genre(Base): @@ -34,6 +37,9 @@ class Genre(Base): texte: Mapped[List['Text_Genre']] = relationship(back_populates='genre') werke: Mapped[List['Werk_Genre']] = relationship(back_populates='genre') + @validates("Genre") + def validate_genre(self, key, value): + return self.validate_not_empty(value) class Herausgeber(Base): __tablename__ = 'Herausgeber' @@ -43,6 +49,10 @@ class Herausgeber(Base): werke: Mapped[List['Werk_Herausgeber']] = relationship(back_populates='herausgeber') + @validates("Herausgeber") + def validate_herausgeber(self, key, value): + return self.validate_not_empty(value) + class Pseudonym(Base): __tablename__ = 'Pseudonym' @@ -52,6 +62,10 @@ class Pseudonym(Base): veroeffentlichung: Mapped[List['Veroeffentlichung']] = relationship(back_populates='pseudonym') + @validates("Pseudonym") + def validate_pseudonym(self, key, value): + return self.validate_not_empty(value) + class Sprache(Base): __tablename__ = 'Sprache' @@ -61,6 +75,10 @@ class Sprache(Base): text: Mapped[List['Text']] = relationship(back_populates='sprache') + @validates("Sprache") + def validate_sprache(self, key, value): + return self.validate_not_empty(value) + class Textform(Base): __tablename__ = 'Textform' @@ -70,6 +88,10 @@ class Textform(Base): text: Mapped[List['Text']] = relationship(back_populates='textform') + @validates("Textform") + def validate_textform(self, key, value): + return self.validate_not_empty(value) + class Titelbild(Base): __tablename__ = 'Titelbild' @@ -92,6 +114,10 @@ class Titelbild(Base): tb["Thumbnail"] = url_for("titelbild.thumbnail", id=self.ID) return tb + @validates("sha256") + def validate_titelbild(self, key, value): + return self.validate_not_empty(value) + class Verlag(Base): __tablename__ = 'Verlag' @@ -102,6 +128,10 @@ class Verlag(Base): reihe: Mapped[List['Reihe']] = relationship(back_populates='verlag') werk: Mapped[List['Werk']] = relationship(back_populates='verlag') + @validates("Verlag") + def validate_verlag(self, key, value): + return self.validate_not_empty(value) + class Werksform(Base): __tablename__ = 'Werksform' @@ -111,6 +141,10 @@ class Werksform(Base): werk: Mapped[List['Werk']] = relationship(back_populates='werksform') + @validates("Werksform") + def validate_werksform(self, key, value): + return self.validate_not_empty(value) + class Reihe(Base): __tablename__ = 'Reihe' @@ -124,6 +158,10 @@ class Reihe(Base): text: Mapped[List['Text']] = relationship(back_populates='reihe') werk: Mapped[List['Werk']] = relationship(back_populates='reihe') + @validates("Titel") + def validate_titel(self, key, value): + return self.validate_not_empty(value) + class Text(Base): __tablename__ = 'Text' @@ -148,6 +186,10 @@ class Text(Base): genres: Mapped[List['Text_Genre']] = relationship(back_populates='text', cascade="all, delete-orphan") genre_ids: AssociationProxy[List["Genre"]] = association_proxy("genres", "Genre", creator=lambda genre_id: Text_Genre(Genre=genre_id)) + @validates("Titel") + def validate_titel(self, key, value): + return self.validate_not_empty(value) + class Werk(Base): __tablename__ = 'Werk' @@ -184,6 +226,10 @@ class Werk(Base): herausgeber: Mapped[List['Werk_Herausgeber']] = relationship(back_populates='werk', cascade="all, delete-orphan") herausgeber_ids: AssociationProxy[List['Herausgeber']] = association_proxy("herausgeber", "Herausgeber", creator=lambda hrsg_id: Werk_Herausgeber(Herausgeber=hrsg_id)) + @validates("Titel") + def validate_titel(self, key, value): + return self.validate_not_empty(value) + class Veroeffentlichung(Base): __tablename__ = 'Veroeffentlichung' From a7244942ed94c3c631736e1f9a3df36987974d07 Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 24 Jul 2025 10:49:01 +0200 Subject: [PATCH 23/24] extended and improved tests; unit tests now mock the DB, integration tests use an empty DB with test values that is reset for each test function --- .flaskenv | 2 +- tests/conftest.py | 10 +++++ tests/integration/test_int_genre.py | 27 ++++++++++++++ tests/unit/test_unit_genre.py | 58 +++++++++++++++++++++++++++++ tests/unit/test_unit_home.py | 4 ++ 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_int_genre.py create mode 100644 tests/unit/test_unit_genre.py create mode 100644 tests/unit/test_unit_home.py diff --git a/.flaskenv b/.flaskenv index 2081c72..6d1f318 100644 --- a/.flaskenv +++ b/.flaskenv @@ -4,7 +4,7 @@ FLASK_APP = "the_works" FLASK_ENV = "development" FLASK_SECRET_KEY = "f8148ee5d95b0a67122b1cab9993f637a6bf29528f584a9f1575af1a55566748" FLASK_TESTING = False -FLASK_MAX_CONTENT_LENGTH = 1024 * 1024 +#FLASK_MAX_CONTENT_LENGTH = 1024 * 1024 FLASK_DEBUG = True FLASK_SQLALCHEMY_DATABASE_URI = "sqlite:///../the_works.sqlite" diff --git a/tests/conftest.py b/tests/conftest.py index e1ffdf8..6f38df7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import pytest from the_works import create_app +from the_works.database import db as _db TEST_DATABASE_URI = "sqlite:///:memory:" @@ -8,6 +9,7 @@ def app(): test_config = { "ENV": "Testing", "SQLALCHEMY_DATABASE_URI": TEST_DATABASE_URI, + "SECRET_KEY": "This is my very secret key", "TESTING": True } app = create_app(test_config) @@ -19,6 +21,14 @@ def app(): # clean up / reset resources here +@pytest.fixture(scope="function") +def db(app): + with app.app_context(): + yield _db + _db.drop_all() + _db.create_all() + + @pytest.fixture() def client(app): return app.test_client() diff --git a/tests/integration/test_int_genre.py b/tests/integration/test_int_genre.py new file mode 100644 index 0000000..d337998 --- /dev/null +++ b/tests/integration/test_int_genre.py @@ -0,0 +1,27 @@ +from sqlalchemy import select +from the_works.database import db +from the_works.models import Genre +from sqlalchemy.exc import IntegrityError +import pytest + +def test_genre_create(client, app): + """Integrated testing of adding a Genre record.""" + response = client.post("/genre/create", data={"form_Genre": "Test-Genre"}, follow_redirects=True) + + # assert there was exactly 1 redirect + assert len(response.history) == 1 + # assert the redirect led to the correct page + assert response.request.path == "/genre/all" + assert response.status_code == 200 + + # assert record was successfully added to DB + with app.app_context(): + genre = db.session.scalars(select(Genre).where(Genre.Genre == "Test-Genre")).all() + assert len(genre) == 1 + assert isinstance(genre[0], Genre) + + # assert uniqueness of records + with pytest.raises(IntegrityError) as excinfo: + response = client.post("/genre/create", data={"form_Genre": "Test-Genre"}) + assert "UNIQUE constraint failed" in str(excinfo.value) + diff --git a/tests/unit/test_unit_genre.py b/tests/unit/test_unit_genre.py new file mode 100644 index 0000000..eaee142 --- /dev/null +++ b/tests/unit/test_unit_genre.py @@ -0,0 +1,58 @@ +from the_works.models import Genre +import pytest + + +def test_genre_all(client, db, mocker): + """Test view all() from genre.py.""" + + # mock database function + # Note: The original method returns an sqlalchemy.engine.Result.ScalarResult, not a list, but the template code + # uses the return value in a way that works for both ScalarResult and list + mocker.patch("the_works.database.db.session.scalars", return_value=[ + Genre(ID=4, Genre="bla"), + Genre(ID=26, Genre="blubb") + ]) + + # test case: get request + response = client.get("/genre") + assert response.status_code == 200 + assert response.data.count(b'\n") From ab97ef4b44af16619ececdd8db8b83a8a00fd18f Mon Sep 17 00:00:00 2001 From: eclipse Date: Thu, 24 Jul 2025 10:49:26 +0200 Subject: [PATCH 24/24] quick script to test stuff via `flask shell` --- tmp.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tmp.py diff --git a/tmp.py b/tmp.py new file mode 100644 index 0000000..99c534e --- /dev/null +++ b/tmp.py @@ -0,0 +1,14 @@ +from flask import Flask +app = Flask(__name__) + +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" +app.config["SQLALCHEMY_ECHO"] = True + +from flask_sqlalchemy import SQLAlchemy +from the_works.models import Base +db = SQLAlchemy(model_class=Base) + +db.init_app(app) + +with app.app_context(): + db.create_all() \ No newline at end of file