diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000..0f47561 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,108 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +coverage-report/ +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# PyCharm +.idea/ + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md new file mode 100644 index 0000000..6c95d97 --- /dev/null +++ b/python/CHANGELOG.md @@ -0,0 +1,5 @@ +# Kontor Change History + +## 0.0.1 + +Initial release. diff --git a/python/Dockerfile b/python/Dockerfile new file mode 100644 index 0000000..d32cf5c --- /dev/null +++ b/python/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.9-alpine +LABEL MAINTAINER="Thomas Peetz " +ENV PS1="\[\e[0;33m\]|> kontor <| \[\e[1;35m\]\W\[\e[0m\] \[\e[0m\]# " + +WORKDIR /src +COPY . /src +RUN pip install --no-cache-dir -r requirements.txt \ + && python setup.py install +WORKDIR / +ENTRYPOINT ["kontor"] diff --git a/python/LICENSE.md b/python/LICENSE.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/python/LICENSE.md @@ -0,0 +1 @@ + diff --git a/python/MANIFEST.in b/python/MANIFEST.in new file mode 100644 index 0000000..1160952 --- /dev/null +++ b/python/MANIFEST.in @@ -0,0 +1,5 @@ +recursive-include *.py +include setup.cfg +include README.md CHANGELOG.md LICENSE.md +include *.txt +recursive-include kontor/templates * diff --git a/python/Makefile b/python/Makefile new file mode 100644 index 0000000..b016c3c --- /dev/null +++ b/python/Makefile @@ -0,0 +1,31 @@ +.PHONY: clean virtualenv test docker dist dist-upload + +clean: + find . -name '*.py[co]' -delete + +virtualenv: + virtualenv --prompt '|> kontor <| ' env + env/bin/pip install -r requirements-dev.txt + env/bin/python setup.py develop + @echo + @echo "VirtualENV Setup Complete. Now run: source env/bin/activate" + @echo + +test: + python -m pytest \ + -v \ + --cov=kontor \ + --cov-report=term \ + --cov-report=html:coverage-report \ + tests/ + +docker: clean + docker build -t kontor:latest . + +dist: clean + rm -rf dist/* + python setup.py sdist + python setup.py bdist_wheel + +dist-upload: + twine upload dist/* diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..6afe593 --- /dev/null +++ b/python/README.md @@ -0,0 +1,69 @@ +# Kontor CLI Tool + +## Installation + +``` +$ pip install -r requirements.txt + +$ python setup.py install +``` + +## Development + +This project includes a number of helpers in the `Makefile` to streamline common development tasks. + +### Environment Setup + +The following demonstrates setting up and working with a development environment: + +``` +### create a virtualenv for development + +$ make virtualenv + +$ source env/bin/activate + + +### run kontor cli application + +$ kontor --help + + +### run pytest / coverage + +$ make test +``` + + +### Releasing to PyPi + +Before releasing to PyPi, you must configure your login credentials: + +**~/.pypirc**: + +``` +[pypi] +username = YOUR_USERNAME +password = YOUR_PASSWORD +``` + +Then use the included helper function via the `Makefile`: + +``` +$ make dist + +$ make dist-upload +``` + +## Deployments + +### Docker + +Included is a basic `Dockerfile` for building and distributing `Kontor`, +and can be built with the included `make` helper: + +``` +$ make docker + +$ docker run -it kontor --help +``` diff --git a/python/config/kontor.yml.example b/python/config/kontor.yml.example new file mode 100644 index 0000000..ba6507c --- /dev/null +++ b/python/config/kontor.yml.example @@ -0,0 +1,46 @@ +### Kontor Configuration Settings +--- + +kontor: + +### Toggle application level debug (does not toggle framework debugging) +# debug: false + +### Where external (third-party) plugins are loaded from +# plugin_dir: /var/lib/kontor/plugins/ + +### Where all plugin configurations are loaded from +# plugin_config_dir: /etc/kontor/plugins.d/ + +### Where external templates are loaded from +# template_dir: /var/lib/kontor/templates/ + +### The log handler label +# log_handler: colorlog + +### The output handler label +# output_handler: jinja2 + +### sample foo option +# foo: bar + + +log.colorlog: + +### Where the log file lives (no log file by default) +# file: null + +### The level for which to log. One of: info, warning, error, fatal, debug +# level: info + +### Whether or not to log to console +# to_console: true + +### Whether or not to rotate the log file when it reaches `max_bytes` +# rotate: false + +### Max size in bytes that a log file can grow until it is rotated. +# max_bytes: 512000 + +### The maximum number of log files to maintain when rotating +# max_files: 4 diff --git a/python/docs/.gitkeep b/python/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/__init__.py b/python/kontor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/controllers/__init__.py b/python/kontor/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/controllers/clibase.py b/python/kontor/controllers/clibase.py new file mode 100644 index 0000000..568a546 --- /dev/null +++ b/python/kontor/controllers/clibase.py @@ -0,0 +1,42 @@ +from PySide6.QtWidgets import QApplication +from cement import Controller, ex +from cement.utils.version import get_version_banner +from ..core.version import get_version +from ..gui.main_window import MainWindow + +VERSION_BANNER = """ +Kontor CLI Tool %s +%s +""" % (get_version(), get_version_banner()) + + +class CliBase(Controller): + class Meta: + label = 'base' + + # text displayed at the top of --help output + description = 'Kontor CLI Tool' + + # text displayed at the bottom of --help output + epilog = 'Usage: kontor gui|database' + + # controller level arguments. ex: 'kontor --version' + arguments = [ + ### add a version banner + (['-v', '--version'], + {'action': 'version', + 'version': VERSION_BANNER}), + ] + + def _default(self): + """Default action if no sub-command is passed.""" + self.gui() + + @ex( + help='start GUI' + ) + def gui(self): + application = QApplication([]) + window = MainWindow(self.app.session, self.app.log) + window.show() + application.exec() diff --git a/python/kontor/controllers/database.py b/python/kontor/controllers/database.py new file mode 100644 index 0000000..246d4f8 --- /dev/null +++ b/python/kontor/controllers/database.py @@ -0,0 +1,32 @@ +from cement import Controller, ex + +from ..database import KontorDB + + +class Database(Controller): + + class Meta: + label = 'database' + stacked_type = 'nested' + stacked_on = 'base' + + @ex( + help='export database to given file', + arguments=[ + (['-f', '--file'], + {'help': 'file to store database content', + 'action': 'store', + 'dest': 'db_file'}) + ], + ) + def export(self): + data = { + 'db_file': 'data.json', + 'export_type': 'JSON', + } + if self.app.pargs.db_file is not None: + data['db_file'] = self.app.pargs.db_file + kontor_db = KontorDB(self.app.session, self.app.log) + table_list = kontor_db.get_table_names() + kontor_db.export_db(data['export_type'], data['db_file'], table_list) + self.app.render(data, 'command1.jinja2') diff --git a/python/kontor/core/__init__.py b/python/kontor/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/core/exc.py b/python/kontor/core/exc.py new file mode 100644 index 0000000..aaeb159 --- /dev/null +++ b/python/kontor/core/exc.py @@ -0,0 +1,4 @@ + +class KontorError(Exception): + """Generic errors.""" + pass diff --git a/python/kontor/core/version.py b/python/kontor/core/version.py new file mode 100644 index 0000000..d130f85 --- /dev/null +++ b/python/kontor/core/version.py @@ -0,0 +1,7 @@ + +from cement.utils.version import get_version as cement_get_version + +VERSION = (0, 0, 1, 'alpha', 0) + +def get_version(version=VERSION): + return cement_get_version(version) diff --git a/python/kontor/database/__init__.py b/python/kontor/database/__init__.py new file mode 100644 index 0000000..c7e6cb5 --- /dev/null +++ b/python/kontor/database/__init__.py @@ -0,0 +1,146 @@ +import json +from datetime import datetime +from pathlib import Path + +from .base import Base +from .comic import Comic, Artist, Publisher, Issue, StoryArc, TradePaperback, Volume, ComicWork, WorkType +from .metadata import MetaDataTable, MetaDataColumn +from .tysc import Card, CardSet, Sport, Team, FieldPosition, Rooster, Player, Vendor +from .media import MediaFile + + +class KontorDB: + + def __init__(self, db_session, log): + self.session = db_session + self.log = log + self.registry = {} + self.init_registry() + + def init_registry(self): + self.registry['card'] = Card + self.registry['card_set'] = CardSet + self.registry['sport'] = Sport + self.registry['team'] = Team + self.registry['field_position'] = FieldPosition + self.registry['rooster'] = Rooster + self.registry['player'] = Player + self.registry['vendor'] = Vendor + self.registry['artist'] = Artist + self.registry['publisher'] = Publisher + self.registry['comic'] = Comic + self.registry['issue'] = Issue + self.registry['story_arc'] = StoryArc + self.registry['trade_paperback'] = TradePaperback + self.registry['volume'] = Volume + self.registry['comic_work'] = ComicWork + self.registry['worktype'] = WorkType + self.registry['media_file'] = MediaFile + + def get_table_names(self) -> list: + tables = self.session.query(MetaDataTable).all() + result = [table.table_name for table in tables] + return result + + def get_column_meta_data(self, table_name: str, view_only=True) -> dict: + meta_data = {} + order = 0 + if view_only: + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name). + filter(MetaDataColumn.is_shown == 1).all()): + meta_data[order] = {'column': column.column_name, 'label': column.column_label, + 'order': column.column_order, 'ref_column': column.ref_column} + order += 1 + else: + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name).all()): + meta_data[order] = { + 'column': column.column_name, + 'order': column.column_order, + 'ref_column': column.ref_column + } + order += 1 + return meta_data + + def get_filters(self, table_name): + _filter_map = {} + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name). + filter(MetaDataColumn.show_filter == 1).all()): + _filter_map[column.column_name] = {'label': column.filter_label, 'widget': None} + self.log.info(f"retrieved {len(_filter_map)} filters: {_filter_map}") + return _filter_map + + def data(self, table, columns: dict, filters) -> list: + data = [] + entries = [] + if len(filters) == 0: + entries = self.session.query(table).all() + else: + entries = self.session.query(table).filter_by(**filters) + for entry in entries: + row = [] + for order in columns.keys(): + column_name = columns[order]['column'] + if str(column_name).endswith("_id"): + ref_table = column_name[:-3] + # print(f"{ref_table=}") + ref = getattr(entry, ref_table) + value = getattr(ref, "name") + # print(f"{value=}") + row.append(value) + else: + row.append(getattr(entry, column_name)) + # print(repr(row)) + data.append(row) + return data + + def export_db(self, export_type: str, export_file_name: str, export_table_list: list): + self.log.info(f"export DB to {export_file_name} as {export_type}") + db = {} + for table in export_table_list: + columns = self.get_column_meta_data(table, view_only=False) + if table in self.registry: + model = self.registry[table] + else: + print(f"table {table} is not registered") + continue + rows = self.session.query(model).all() + entries = [] + self.log.debug(f"found {len(rows)} entries") + self.log.debug(f"found {len(columns)} columns") + for row in rows: + # print(row) + entry = {} + for order in columns: + # print(columns[order]) + column_name = columns[order]['column'] + # print(f"get value {column_name} from {row} of table {table}") + try: + value = getattr(row, column_name) + if isinstance(value, datetime): + entry[column_name] = str(value) + else: + entry[column_name] = value + except AttributeError as error: + self.log.debug("could not get value") + entries.append(entry) + db[table] = entries + export_file = Path(export_file_name) + match export_type: + case "JSON": + json_dump = json.dumps(db, indent=4) + with open(export_file_name, "w") as dump_file: + dump_file.write(json_dump) + case "YAML": + export_file = Path(export_file_name) + case "SQLite": + export_file = Path(export_file_name) + case _: + self.log.debug("unknown export type") + if export_file.exists(): + self.log.debug(f"{export_file} exists") diff --git a/python/kontor/database/base.py b/python/kontor/database/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/python/kontor/database/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/python/kontor/database/comic.py b/python/kontor/database/comic.py new file mode 100644 index 0000000..49d222f --- /dev/null +++ b/python/kontor/database/comic.py @@ -0,0 +1,130 @@ +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from .base import Base + + +class Publisher(Base): + __tablename__ = "publisher" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), unique=True) + comics = relationship("Comic") + + def __repr__(self): + return f'Publisher({self.id} {self.name})' + + def __str__(self): + return self.__repr__() + + +class Comic(Base): + __tablename__ = 'comic' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + title = Column(String(length=255), unique=True) + publisher_id = Column(String, ForeignKey('publisher.id'), nullable=False) + publisher = relationship("Publisher", back_populates="comics") + current_order = Column(BIT(1)) + completed = Column(BIT(1)) + issues = relationship("Issue") + story_arcs = relationship("StoryArc") + trade_paperbacks = relationship("TradePaperback") + volumes = relationship("Volume") + comic_works = relationship("ComicWork") + + def __repr__(self): + return f'Comic({self.id} {self.version} {self.title} {self.publisher.name})' + + def __str__(self): + return f'{self.title}({self.id})' + + +class Volume(Base): + __tablename__ = "volume" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="volumes") + issues = relationship("Issue") + + +class TradePaperback(Base): + __tablename__ = "trade_paperback" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + issue_start = Column(Integer) + issue_end = Column(Integer) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="trade_paperbacks") + + +class StoryArc(Base): + __tablename__ = "story_arc" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="story_arcs") + + +class Issue(Base): + __tablename__ = "issue" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + issue_number = Column(String(255)) + in_stock = Column(BIT(1)) + is_read = Column(BIT(1)) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="issues") + volume_id = Column(String, ForeignKey("volume.id"), nullable=True) + volume = relationship("Volume", back_populates="issues") + + +class Artist(Base): + __tablename__ = "artist" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False) + comic_works = relationship("ComicWork") + + +class WorkType(Base): + __tablename__ = "worktype" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(length=255), nullable=False, unique=True) + comic_works = relationship("ComicWork") + + +class ComicWork(Base): + __tablename__ = "comic_work" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + comic_id = Column(String, ForeignKey("comic.id"), nullable=False) + comic = relationship("Comic", back_populates="comic_works") + artist_id = Column(String, ForeignKey("artist.id"), nullable=False) + artist = relationship("Artist", back_populates="comic_works") + work_type_id = Column(String, ForeignKey("worktype.id"), nullable=False) + work_type = relationship("WorkType", back_populates="comic_works") diff --git a/python/kontor/database/media.py b/python/kontor/database/media.py new file mode 100644 index 0000000..0129c03 --- /dev/null +++ b/python/kontor/database/media.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy.dialects.mysql import BIT + +from .base import Base + + +class MediaFile(Base): + __tablename__ = 'media_file' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + cloud_link = Column(String(255)) + file_name = Column(String(255)) + path = Column(String(255)) + review = Column(BIT(1)) + title = Column(String(255)) + url = Column(String(255)) + should_download = Column(BIT(1)) + + def __repr__(self): + return f'MediaFile({self.id} {self.title} {self.title})' + + def __str__(self): + return f'{self.title}({self.id})' diff --git a/python/kontor/database/metadata.py b/python/kontor/database/metadata.py new file mode 100644 index 0000000..d7dd4c0 --- /dev/null +++ b/python/kontor/database/metadata.py @@ -0,0 +1,50 @@ +from sqlalchemy import Column, String, ForeignKey, DateTime, Integer, Boolean +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from .base import Base + + +class MetaDataTable(Base): + __tablename__ = 'meta_data_table' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + table_name = Column(String(255), unique=True) + table_columns = relationship("MetaDataColumn") + + def __repr__(self): + return f'MetaDataTable({self.id} {self.table_name})' + + def __str__(self): + return f'{self.table_name}({self.id})' + + +class MetaDataColumn(Base): + __tablename__ = 'meta_data_column' + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + column_modifier = Column(String(255), nullable=True) + column_name = Column(String(255)) + column_order = Column(Integer) + column_sync_name = Column(String(255)) + column_type = Column(String(255)) + table_id = Column(String, ForeignKey('meta_data_table.id')) + table = relationship("MetaDataTable", back_populates="table_columns") + column_label = Column(String(255)) + filter_label = Column(String(255)) + is_shown = Column(BIT(1)) + show_filter = Column(BIT(1)) + ref_column = Column(String, nullable=True) + + def __repr__(self): + if self.column_name is None: + return f'MetaDataColumn({self.id} {self.table.table_name}.__)' + else: + return f'MetaDataColumn({self.id} {self.table.table_name}.{self.column_name})' + + def __str__(self): + return f'{self.column_name}({self.id})' diff --git a/python/kontor/database/tysc.py b/python/kontor/database/tysc.py new file mode 100644 index 0000000..733d7ed --- /dev/null +++ b/python/kontor/database/tysc.py @@ -0,0 +1,131 @@ +from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, UniqueConstraint +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from .base import Base + + +class Sport(Base): + __tablename__ = "sport" + __table_args__ = ( + UniqueConstraint("name"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, index=True, unique=True) + teams = relationship("Team") + positions = relationship("FieldPosition") + + +class Team(Base): + __tablename__ = "team" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, index=True, unique=True) + short_name = Column(String(255), nullable=False, ) + sport_id = Column(String, ForeignKey("sport.id"), nullable=False) + sport = relationship("Sport", back_populates="teams") + roosters = relationship("Rooster") + + +class FieldPosition(Base): + __tablename__ = "field_position" + __table_args__ = ( + UniqueConstraint("name", "sport_id"), + UniqueConstraint("short_name", "sport_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, index=True) + short_name = Column(String(255), nullable=False) + sport_id = Column(String, ForeignKey("sport.id"), nullable=False, index=True) + sport = relationship("Sport", back_populates="positions") + roosters = relationship("Rooster") + + +class Player(Base): + __tablename__ = "player" + __table_args__ = ( + UniqueConstraint("first_name", "last_name"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + first_name = Column(String(255), nullable=False, index=True) + last_name = Column(String(255), nullable=False, index=True) + roosters = relationship("Rooster") + + def get_full_name(self) -> str: + return f"{self.last_name}, {self.first_name}" + + +class Rooster(Base): + __tablename__ = "rooster" + __table_args__ = ( + UniqueConstraint("year", "team_id", "player_id", "position_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + year = Column(Integer) + team_id = Column(String, ForeignKey("team.id"), nullable=False, index=True) + team = relationship("Team", back_populates="roosters") + player_id = Column(String, ForeignKey("player.id"), nullable=False, index=True) + player = relationship("Player", back_populates="roosters") + position_id = Column(String, ForeignKey("field_position.id"), nullable=False, index=True) + position = relationship("FieldPosition", back_populates="roosters") + cards = relationship("Card") + +class Vendor(Base): + __tablename__ = "vendor" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), nullable=False, unique=True, index=True) + card_sets = relationship("CardSet") + cards = relationship("Card") + + +class CardSet(Base): + __tablename__ = "card_set" + __table_args__ = ( + UniqueConstraint("name", "vendor_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255), index=True) + parallel_set = Column(BIT(1)) + insert_set = Column(BIT(1)) + vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False, index=True) + vendor = relationship("Vendor", back_populates="card_sets") + cards = relationship("Card") + + +class Card(Base): + __tablename__ = "card" + __table_args__ = ( + UniqueConstraint("card_number", "year", "vendor_id", "card_set_id"), + ) + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + card_number = Column(Integer, index=True) + year = Column(Integer, index=True) + card_set_id = Column(String, ForeignKey("card_set.id"), nullable=False) + card_set = relationship("CardSet", back_populates="cards") + rooster_id = Column(String, ForeignKey("rooster.id"), nullable=False) + rooster = relationship("Rooster", back_populates="cards") + vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False) + vendor = relationship("Vendor", back_populates="cards") diff --git a/python/kontor/ext/__init__.py b/python/kontor/ext/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/gui/__init__.py b/python/kontor/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/gui/data_view.py b/python/kontor/gui/data_view.py new file mode 100644 index 0000000..91c66d2 --- /dev/null +++ b/python/kontor/gui/data_view.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class DataViewMeta(ABC): + @abstractmethod + def get_header(self): + pass + + +class ComicView(DataViewMeta): + def get_header(self): + pass diff --git a/python/kontor/gui/data_view_model.py b/python/kontor/gui/data_view_model.py new file mode 100644 index 0000000..4ffdc8c --- /dev/null +++ b/python/kontor/gui/data_view_model.py @@ -0,0 +1,32 @@ +from typing import List + +from PySide6.QtCore import QModelIndex, QAbstractTableModel +from PySide6.QtGui import Qt + +from gui.data_view import DataViewMeta + + +class DataViewModel(QAbstractTableModel): + def __init__(self): + super().__init__() + self.main_window = None + self._config = None + self._data = List[DataViewMeta] + + def rowCount(self, parent=QModelIndex()): + return len(self._data) + + def columnCount(self, parent=QModelIndex()): + return 0 + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + return None + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + return None + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + return False + + def flags(self, index): + return None diff --git a/python/kontor/gui/dialogs.py b/python/kontor/gui/dialogs.py new file mode 100644 index 0000000..21dbe92 --- /dev/null +++ b/python/kontor/gui/dialogs.py @@ -0,0 +1,106 @@ +from pathlib import Path + +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QFileDialog, \ + QCheckBox, QComboBox + + +class ExportKontorDialog(QDialog): + def __init__(self, parent=None, kontor_db=None): + super().__init__(parent) + + self.parent = parent + self.kontor_db = kontor_db + self.file_name = "data.json" + self.tables = [] + self._table_options = {} + + self.export_options = {"JSON": {"ext": ".json"}, "YAML": {"ext": ".yaml"}, "SQLite": {"ext": ".db"}} + self.current_export_type = "JSON" + + buttons = (QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + + self.buttonBox = QDialogButtonBox(buttons) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + layout = QVBoxLayout() + + self.label = QLabel() + self.label.setText("Export DB to data.json") + + self.combo_box = QComboBox() + self.combo_box.addItems(["JSON", "YAML", "SQLite"]) + self.combo_box.currentTextChanged.connect(self.change_export_type) + file_layout = QHBoxLayout() + file_layout.addWidget(self.label) + file_layout.addWidget(self.combo_box) + file_button = QPushButton("Select file") + file_button.clicked.connect(self.select_file) + file_layout.addWidget(file_button) + layout.addLayout(file_layout) + + for table_name in self.kontor_db.get_table_names(): + check_box = QCheckBox(table_name) + check_box.setChecked(True) + self.tables.append(table_name) + self._table_options[table_name] = check_box + check_box.stateChanged.connect(self.change_selection) + layout.addWidget(check_box) + layout.addWidget(self.buttonBox) + self.setLayout(layout) + + def change_selection(self): + self.tables.clear() + for (name, box) in self._table_options.items(): + if box.isChecked(): + self.tables.append(name) + + def change_export_type(self, text): + self.current_export_type = text + self.label.setText(f'Export DB to data.{self.export_options[text]["ext"]}') + + def select_file(self): + file_dialog = QFileDialog() + file_dialog.setFileMode(QFileDialog.FileMode.AnyFile) + file_dialog.setDefaultSuffix(self.export_options[self.current_export_type]["ext"]) + file_dialog.setNameFilter(f'*{self.export_options[self.current_export_type]["ext"]}') + if file_dialog.exec(): + self.file_name = file_dialog.selectedFiles()[0] + export_file = Path(self.file_name) + self.file_name = export_file.with_suffix(self.export_options[self.current_export_type]["ext"]) + self.label.setText(f"Export DB to {self.file_name}") + + def get_tables_to_export(self) -> list: + return self.tables + + +class ImportKontorDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.file_name = None + + QBtn = (QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.label = QLabel() + self.label.setText("Import DB from data.json") + layout = QVBoxLayout() + file_layout = QHBoxLayout() + file_layout.addWidget(self.label) + file_button = QPushButton("Select file") + file_button.clicked.connect(self.select_file) + file_layout.addWidget(file_button) + layout.addLayout(file_layout) + layout.addWidget(self.buttonBox) + self.setLayout(layout) + + def select_file(self): + file_dialog = QFileDialog() + file_dialog.setFileMode(QFileDialog.FileMode.ExistingFile) + if file_dialog.exec(): + self.file_name = file_dialog.selectedFiles()[0] + self.label.setText(f"Import DB from {self.file_name}") diff --git a/python/kontor/gui/main_window.py b/python/kontor/gui/main_window.py new file mode 100644 index 0000000..24a84a6 --- /dev/null +++ b/python/kontor/gui/main_window.py @@ -0,0 +1,139 @@ +from PySide6.QtGui import QAction, QIcon +from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidget, QTableView +from PySide6.QtWidgets import QLabel, QMainWindow + +from ..database import KontorDB +from ..database.media import MediaFile +from ..database.comic import Comic +from .dialogs import ExportKontorDialog, ImportKontorDialog +from .model_config import KontorModelConfig +from .table_model import KontorTableModel + + +class MainWindow(QMainWindow): + + def __init__(self, session, log): + super().__init__() + + self.tick = QIcon('kontor/res/tick.png') + self.cross = QIcon('kontor/res/cross.png') + self.import_icon = QIcon("kontor/res/application-import.png") + self.export_icon = QIcon("kontor/res/application-export.png") + self.circle_icon = QIcon("kontor/res/arrow-circle-double.png") + + self.setWindowTitle("Kontor") + self.setMinimumSize(800, 500) + self._create_actions() + self._create_menubar() + self._create_toolbars() + self._create_statusbar() + + self.data = [] + self.filter = {} + self.kontor_db = KontorDB(session, log) + self.log = log + self.central_widget = QWidget() + parent_layout = QVBoxLayout() + self.central_widget.setLayout(parent_layout) + self.tabs = QTabWidget() + self.tabs.addTab(self.generate_data_tab("comic", Comic), "Comics") + self.tabs.addTab(self.generate_data_tab("media_file", MediaFile), "MediaFile") + self.tabs.currentChanged.connect(self._tab_changed) + #label.setAlignment(Qt.AlignmentFlag.AlignCenter) + parent_layout.addWidget(self.tabs) + + self.setCentralWidget(self.central_widget) + + def _create_actions(self): + self.newAction = QAction("&New", self) + self.aboutAction = QAction("&Über...", self) + self.aboutAction.triggered.connect(self.about) + self.importAction = QAction(self.import_icon, "&Import", self) + self.importAction.triggered.connect(self.import_from_file) + self.exportAction = QAction(self.export_icon, "&Export", self) + self.exportAction.triggered.connect(self.export_to_file) + self.refreshAction = QAction(self.circle_icon, "&Refresh", self) + self.refreshAction.triggered.connect(self.refresh) + self.updateTitleAction = QAction("&Update Titles", self) + self.downloadAction = QAction("&Download Videos", self) + self.exitAction = QAction("&Beenden", self) + self.exitAction.setShortcut("Alt+F4") + self.exitAction.triggered.connect(self.close) + + def _create_menubar(self): + menu_bar = self.menuBar() + # File menu + file_menu = QMenu("&Datei") + menu_bar.addMenu(file_menu) + file_menu.addAction(self.exitAction) + # Kontor menu + kontor_menu = QMenu("&Kontor") + menu_bar.addMenu(kontor_menu) + kontor_menu.addAction(self.importAction) + kontor_menu.addAction(self.exportAction) + comic_menu = QMenu("&Comic") + tysc_menu = QMenu("&TradeYourSportCards") + media_file_menu = QMenu("&MediaFile") + media_file_menu.addAction(self.updateTitleAction) + media_file_menu.addAction(self.downloadAction) + kontor_menu.addMenu(comic_menu) + kontor_menu.addMenu(tysc_menu) + kontor_menu.addMenu(media_file_menu) + # Help menu + help_menu = QMenu("&Hilfe") + menu_bar.addMenu(help_menu) + help_menu.addAction(self.aboutAction) + + def _create_toolbars(self): + # Kontor toolbar + kontor_tool_bar = self.addToolBar("Kontor") + kontor_tool_bar.addAction(self.importAction) + kontor_tool_bar.addAction(self.exportAction) + kontor_tool_bar.addAction(self.refreshAction) + + def _create_statusbar(self): + self.statusBar = self.statusBar() + self.statusBar.showMessage("Kontor ready", 6000) + self.status_label = QLabel("") + self.statusBar.addPermanentWidget(self.status_label) + + def about(self): + QMessageBox.about(self.central_widget, "Über Kontor", f"Python: 3.11\nKontor: 0.1.0") + + def import_from_file(self): + import_dlg = ImportKontorDialog(self) + if import_dlg.exec(): + print(f"import DB from file {import_dlg.file_name}") + else: + print("no nothing for import") + pass + + def export_to_file(self): + export_dlg = ExportKontorDialog(self, self.kontor_db) + if export_dlg.exec(): + self.log.info(export_dlg.get_tables_to_export()) + self.log.info(f"export DB to {export_dlg.file_name}") + self.statusBar.showMessage(f"export DB to {export_dlg.file_name}", 3000) + self.kontor_db.export_db(export_dlg.current_export_type, export_dlg.file_name, export_dlg.get_tables_to_export()) + else: + self.statusBar.showMessage("Export cancelled", 3000) + + def refresh(self): + self.data[self.tabs.currentIndex()].refresh() + + def _tab_changed(self, tab_index): + self.data[tab_index].refresh() + + def generate_data_tab(self, table_name, table): + data_tab = QWidget() + table_config = KontorModelConfig(self.kontor_db, self, table_name, table) + model = KontorTableModel(table_config) + layout = QVBoxLayout() + self.data.append(model) + data_tab.setLayout(layout) + table_view = QTableView() + table_view.setModel(model) + layout.addLayout(table_config.get_filter_layout()) + layout.addWidget(table_view) + model.refresh() + return data_tab diff --git a/python/kontor/gui/model_config.py b/python/kontor/gui/model_config.py new file mode 100644 index 0000000..ce61107 --- /dev/null +++ b/python/kontor/gui/model_config.py @@ -0,0 +1,50 @@ +import mariadb +from PySide6.QtWidgets import QHBoxLayout, QCheckBox + +from ..database import KontorDB + + +class KontorModelConfig: + + def __init__(self, kontor_db: KontorDB, main_window, table_name: str, table): + self.header = {} + self.filter = {} + self.main_window = main_window + self._table_name = table_name + self._table = table + self.kontor_db = kontor_db + self.get_table_config() + + def get_table_config(self): + self.header = self.kontor_db.get_column_meta_data(self._table_name) + self.filter = self.kontor_db.get_filters(self._table_name) + + def filters(self) -> dict: + _filters = {} + # print(self.filter["download"].isChecked()) + for column, filter_info in self.filter.items(): + # print(column, filter_info) + if filter_info['widget'].isChecked(): + _filters[column] = True + # print(f"{filter_rule=}") + return _filters + + def get_data(self) -> list: + # data = self.kontor_db.get_data(self._table_name, self.header, self.get_filter()) + # data.clear() + data = self.kontor_db.data(self._table, self.header, self.filters()) + # print(f"KontorModelConfig.get_data: {len(data)}") + # comics = self.kontor_db.session.query(Comic).all() + # print(f'{len(comics)} Comics loaded') + return data + + def get_filter_layout(self) -> QHBoxLayout: + filter_layout = QHBoxLayout() + for column, filter_info in self.filter.items(): + filter_checkbox = QCheckBox() + filter_checkbox.setText(filter_info['label']) + filter_checkbox.checkStateChanged.connect(self.main_window.refresh) + self.filter[column]['widget'] = filter_checkbox + filter_layout.addWidget(filter_checkbox) + filter_layout.addStretch() + return filter_layout diff --git a/python/kontor/gui/table_model.py b/python/kontor/gui/table_model.py new file mode 100644 index 0000000..cd08ce1 --- /dev/null +++ b/python/kontor/gui/table_model.py @@ -0,0 +1,108 @@ +from datetime import datetime + +from PySide6.QtCore import QAbstractTableModel, QModelIndex +from PySide6.QtGui import Qt + +from .model_config import KontorModelConfig + + +class KontorTableModel(QAbstractTableModel): + + def __init__(self, model_config: KontorModelConfig): + super().__init__() + self._main_window = model_config.main_window + self._config = model_config + self._data = [] + + def refresh(self): + data = self._config.get_data() + count = 0 + # print(data) + if data is not None: + self.beginResetModel() + self._data.clear() + self._data = data + self.endResetModel() + count = len(data) + # print(data) + # print(self._data) + self.layoutChanged.emit() + self._main_window.statusBar.showMessage(f"{count} Einträge geladen", 3000) + + def rowCount(self, parent=QModelIndex()): + # The length of the outer list. + if self._data is None: + return 0 + return len(self._data) + + def headerData(self, col, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return self._config.header[col]['label'] + if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole: + return str(col+1) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if self._data is None: + return None + value = self._data[index.row()][index.column()] + # print('{}:: {}:: {}: {}'.format(index, role, value, type(value))) + if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole: + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %M:%M:%S") + if isinstance(value, str): + return value + if isinstance(value, bytes): + if value == b'\x01': + return self._main_window.tick + else: + return self._main_window.cross + if isinstance(value, int): + # print('{}:: {}: {}'.format(index, value, type(value))) + if value == 1: + return self._main_window.tick + else: + return self._main_window.cross + if isinstance(value, bool): + if value: + return self._main_window.tick + else: + return self._main_window.cross + return str(value) + if role == Qt.ItemDataRole.DecorationRole: + if isinstance(value, bytes): + if value == b'\x01': + return self._main_window.tick + else: + return self._main_window.cross + if isinstance(value, int): + if value == 1: + return self._main_window.tick + else: + return self._main_window.cross + if isinstance(value, bool): + if value: + return self._main_window.tick + else: + return self._main_window.cross + + def columnCount(self, index=QModelIndex()): + # The following takes the first sub-list, and returns + # the length (only works if all rows are an equal length) + # print(f"Header count: {len(self._config.get_header())}") + return len(self._config.header) + + def setData(self, index, value, role: int) -> bool: + print(index, role) + if role == Qt.ItemDataRole.EditRole: + self._data[index.row()][index.column()] = value + print(self._data[index.row()][index.column()]) + self.dataChanged.emit(index, index) + return True + if role == Qt.ItemDataRole.CheckStateRole: + print("role == Qt.ItemDataRole.CheckStateRole") + checked = value == Qt.CheckState.Checked + self._data[index.row()][index.column()] = checked + return False + + def flags(self, index): + return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsUserTristate diff --git a/python/kontor/main.py b/python/kontor/main.py new file mode 100644 index 0000000..aa71e06 --- /dev/null +++ b/python/kontor/main.py @@ -0,0 +1,119 @@ +from cement import App, TestApp, init_defaults +from cement.core.exc import CaughtSignal +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from .core.exc import KontorError +from .database.base import Base +from .controllers.clibase import CliBase +from .controllers.database import Database + +# configuration defaults +CONFIG = init_defaults('kontor', 'mariadb') +CONFIG['kontor']['foo'] = 'bar' +CONFIG['mariadb']['user'] = 'kontor' +CONFIG['mariadb']['password'] = 'kontor' +CONFIG['mariadb']['host'] = '127.0.0.1' +CONFIG['mariadb']['port'] = '3306' +CONFIG['mariadb']['database'] = 'kontor' + + +def extend_sqlalchemy(app): + app.log.info('extending kontor application with sqlalchemy') + connect_string = ('mariadb+mariadbconnector://{}:{}@{}:{}/{}'.format( + app.config.get('mariadb', 'user'), + app.config.get('mariadb', 'password'), + app.config.get('mariadb', 'host'), + app.config.get('mariadb', 'port'), + app.config.get('mariadb', 'database') + )) + # engine = create_engine(connect_string, echo=True) + engine = create_engine(connect_string) + Base.metadata.create_all(bind=engine) + __session__ = sessionmaker(bind=engine) + app.extend('session', __session__()) + + +def close_session(app): + app.log.info('close session') + app.session.close() + + +class Kontor(App): + """Kontor primary application.""" + + class Meta: + label = 'kontor' + + # configuration defaults + config_defaults = CONFIG + + # call sys.exit() on close + exit_on_close = True + + # load additional framework extensions + extensions = [ + 'yaml', + 'colorlog', + 'jinja2', + ] + + # configuration handler + config_handler = 'yaml' + + # configuration file suffix + config_file_suffix = '.yml' + + # set the log handler + log_handler = 'colorlog' + + # set the output handler + output_handler = 'jinja2' + + hooks = [ + ('post_setup', extend_sqlalchemy), + ('pre_close', close_session), + ] + # register handlers + handlers = [ + CliBase, + Database, + ] + + +class KontorTest(TestApp, Kontor): + """A sub-class of Kontor that is better suited for testing.""" + + class Meta: + label = 'kontor' + + +def main(): + with Kontor() as app: + try: + app.run() + + except AssertionError as e: + print('AssertionError > %s' % e.args[0]) + app.exit_code = 1 + + if app.debug is True: + import traceback + traceback.print_exc() + + except KontorError as e: + print('KontorError > %s' % e.args[0]) + app.exit_code = 1 + + if app.debug is True: + import traceback + traceback.print_exc() + + except CaughtSignal as e: + # Default Cement signals are SIGINT and SIGTERM, exit 0 (non-error) + print('\n%s' % e) + app.exit_code = 0 + + +if __name__ == '__main__': + main() diff --git a/python/kontor/plugins/__init__.py b/python/kontor/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/res/application-export.png b/python/kontor/res/application-export.png new file mode 100644 index 0000000..555887a Binary files /dev/null and b/python/kontor/res/application-export.png differ diff --git a/python/kontor/res/application-import.png b/python/kontor/res/application-import.png new file mode 100644 index 0000000..922cb07 Binary files /dev/null and b/python/kontor/res/application-import.png differ diff --git a/python/kontor/res/arrow-circle-double.png b/python/kontor/res/arrow-circle-double.png new file mode 100644 index 0000000..ba5ebd1 Binary files /dev/null and b/python/kontor/res/arrow-circle-double.png differ diff --git a/python/kontor/res/cross.png b/python/kontor/res/cross.png new file mode 100644 index 0000000..6b9fa6d Binary files /dev/null and b/python/kontor/res/cross.png differ diff --git a/python/kontor/res/tick.png b/python/kontor/res/tick.png new file mode 100644 index 0000000..2414885 Binary files /dev/null and b/python/kontor/res/tick.png differ diff --git a/python/kontor/templates/__init__.py b/python/kontor/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/kontor/templates/command1.jinja2 b/python/kontor/templates/command1.jinja2 new file mode 100644 index 0000000..2435e4d --- /dev/null +++ b/python/kontor/templates/command1.jinja2 @@ -0,0 +1,4 @@ + +Example Template (templates/command1.jinja2) + +Foo => {{ foo }} diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt new file mode 100644 index 0000000..f20606e --- /dev/null +++ b/python/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt + +pytest +pytest-cov +coverage +twine>=1.11.0 +setuptools>=38.6.0 +wheel>=0.31.0 diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..06a15f9 --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,7 @@ +cement==3.0.12 +cement[jinja2] +cement[yaml] +cement[colorlog] +mariadb +sqlalchemy +PySide6 diff --git a/python/setup.cfg b/python/setup.cfg new file mode 100644 index 0000000..e69de29 diff --git a/python/setup.py b/python/setup.py new file mode 100644 index 0000000..0b92a43 --- /dev/null +++ b/python/setup.py @@ -0,0 +1,28 @@ + +from setuptools import setup, find_packages +from kontor.core.version import get_version + +VERSION = get_version() + +f = open('README.md', 'r') +LONG_DESCRIPTION = f.read() +f.close() + +setup( + name='kontor', + version=VERSION, + description='Kontor CLI Tool', + long_description=LONG_DESCRIPTION, + long_description_content_type='text/markdown', + author='Thomas Peetz', + author_email='thomas.peetz@thpeetz.de', + url='https://gitlab.com/tpeetz/kontor', + license='MIT', + packages=find_packages(exclude=['ez_setup', 'tests*']), + package_data={'kontor': ['templates/*']}, + include_package_data=True, + entry_points=""" + [console_scripts] + kontor = kontor.main:main + """, +) diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..5124e2e --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,16 @@ +""" +PyTest Fixtures. +""" + +import pytest +from cement import fs + +@pytest.fixture(scope="function") +def tmp(request): + """ + Create a `tmp` object that geneates a unique temporary directory, and file + for each test function that requires it. + """ + t = fs.Tmp() + yield t + t.remove() diff --git a/python/tests/test_kontor.py b/python/tests/test_kontor.py new file mode 100644 index 0000000..3c1bd67 --- /dev/null +++ b/python/tests/test_kontor.py @@ -0,0 +1,36 @@ + +from pytest import raises +from kontor.main import KontorTest + +def test_kontor(): + # test kontor without any subcommands or arguments + with KontorTest() as app: + app.run() + assert app.exit_code == 0 + + +def test_kontor_debug(): + # test that debug mode is functional + argv = ['--debug'] + with KontorTest(argv=argv) as app: + app.run() + assert app.debug is True + + +def test_command1(): + # test command1 without arguments + argv = ['command1'] + with KontorTest(argv=argv) as app: + app.run() + data,output = app.last_rendered + assert data['foo'] == 'bar' + assert output.find('Foo => bar') + + + # test command1 with arguments + argv = ['command1', '--foo', 'not-bar'] + with KontorTest(argv=argv) as app: + app.run() + data,output = app.last_rendered + assert data['foo'] == 'not-bar' + assert output.find('Foo => not-bar') diff --git a/qt/database/__init__.py b/qt/database/__init__.py index c0191cc..47f5706 100644 --- a/qt/database/__init__.py +++ b/qt/database/__init__.py @@ -1,10 +1,16 @@ +import json +from datetime import datetime +from pathlib import Path + import mariadb from sqlalchemy import create_engine, select, text, MetaData, join from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker -from database.base import Base -from database.comic import Comic -from database.metadata import MetaDataTable, MetaDataColumn +from .base import Base +from .comic import Comic, Artist, Publisher, ComicWork, WorkType, StoryArc, Volume, Issue, TradePaperback +from .tysc import Sport, Team, Card, CardSet, Vendor, Rooster, Player, FieldPosition +from .media import MediaFile +from .metadata import MetaDataTable, MetaDataColumn class KontorDB: @@ -29,36 +35,91 @@ class KontorDB: Base.metadata.create_all(bind=engine) __session__ = sessionmaker(bind=engine) self.session = __session__() + self.registry = {} + self.init_registry() + + def init_registry(self): + self.registry['card'] = Card + self.registry['card_set'] = CardSet + self.registry['sport'] = Sport + self.registry['team'] = Team + self.registry['field_position'] = FieldPosition + self.registry['rooster'] = Rooster + self.registry['player'] = Player + self.registry['vendor'] = Vendor + self.registry['artist'] = Artist + self.registry['publisher'] = Publisher + self.registry['comic'] = Comic + self.registry['issue'] = Issue + self.registry['story_arc'] = StoryArc + self.registry['trade_paperback'] = TradePaperback + self.registry['volume'] = Volume + self.registry['comic_work'] = ComicWork + self.registry['worktype'] = WorkType + self.registry['media_file'] = MediaFile - def get_table_id(self, table_name): - result = self.session.execute(select(MetaDataTable.id).where(MetaDataTable.table_name == table_name)).scalar() - return result def get_table_names(self) -> list: tables = self.session.query(MetaDataTable).all() result = [table.table_name for table in tables] return result - def get_column_meta_data(self, table_id: str, table_name: str) -> dict: + def get_column_meta_data(self, table_name: str, view_only=True) -> dict: meta_data = {} order = 0 - for (_, column) in self.session.query(MetaDataTable, MetaDataColumn).filter(MetaDataTable.id == MetaDataColumn.table_id).filter(MetaDataTable.table_name == table_name).filter(MetaDataColumn.is_shown == 1).all(): - meta_data[order] = {'column': column.column_name, 'label': column.column_label, 'order': column.column_order, 'ref_column': column.ref_column} - order += 1 + if view_only: + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name). + filter(MetaDataColumn.is_shown == 1).all()): + meta_data[order] = {'column': column.column_name, 'label': column.column_label, + 'order': column.column_order, 'ref_column': column.ref_column} + order += 1 + else: + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name).all()): + meta_data[order] = { + 'column': column.column_name, + 'order': column.column_order, + 'ref_column': column.ref_column + } + order += 1 return meta_data - def get_filters(self, table_id): - cursor = self.db_conn.cursor() - filters = {} - cursor.execute( - "SELECT column_name, filter_label from meta_data_column WHERE table_id=? AND show_filter is true", - (table_id,)) - rows = cursor.fetchall() - for row in rows: - filters[row[0]] = {'label': row[1], 'widget': None} - cursor.close() - # print(f"retrieved {len(rows)} filters: {filters}") - return filters + def get_filters(self, table_name): + _filter_map = {} + for (_, column) in (self.session.query(MetaDataTable, MetaDataColumn). + filter(MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name). + filter(MetaDataColumn.show_filter == 1).all()): + _filter_map[column.column_name] = {'label': column.filter_label, 'widget': None} + print(f"retrieved {len(_filter_map)} filters: {_filter_map}") + return _filter_map + + def data(self, table, columns: dict, filters) -> list: + data = [] + entries = [] + if len(filters) == 0: + entries = self.session.query(table).all() + else: + entries = self.session.query(table).filter_by(**filters) + for entry in entries: + row = [] + for order in columns.keys(): + column_name = columns[order]['column'] + if str(column_name).endswith("_id"): + ref_table = column_name[:-3] + # print(f"{ref_table=}") + ref = getattr(entry, ref_table) + value = getattr(ref, "name") + # print(f"{value=}") + row.append(value) + else: + row.append(getattr(entry, column_name)) + # print(repr(row)) + data.append(row) + return data def get_data(self, table_name: str, columns: dict, where_clause: str) -> list: data = [] @@ -103,3 +164,49 @@ class KontorDB: statement = f"SELECT {columns} FROM {table} {where_clause}" print(f"{statement=}") return statement + + def export_db(self, export_type: str, export_file_name: str, export_table_list: list): + print(f"export DB to {export_file_name} as {export_type}") + db = {} + for table in export_table_list: + columns = self.get_column_meta_data(table, view_only=False) + if table in self.registry: + model = self.registry[table] + else: + print(f"table {table} is not registered") + continue + rows = self.session.query(model).all() + entries = [] + print(f"found {len(rows)} entries") + print(f"found {len(columns)} columns") + for row in rows: + # print(row) + entry = {} + for order in columns: + # print(columns[order]) + column_name = columns[order]['column'] + # print(f"get value {column_name} from {row} of table {table}") + try: + value = getattr(row, column_name) + if isinstance(value, datetime): + entry[column_name] = str(value) + else: + entry[column_name] = value + except AttributeError as error: + print("could not get value") + entries.append(entry) + db[table] = entries + export_file = Path(export_file_name) + match export_type: + case "JSON": + json_dump = json.dumps(db, indent=4) + with open(export_file_name, "w") as dump_file: + dump_file.write(json_dump) + case "YAML": + export_file = Path(export_file_name) + case "SQLite": + export_file = Path(export_file_name) + case _: + print("unknown export type") + if export_file.exists(): + print(f"{export_file} exists") diff --git a/qt/database/base.py b/qt/database/base.py index 9339e51..97d2990 100644 --- a/qt/database/base.py +++ b/qt/database/base.py @@ -1,7 +1,18 @@ -from sqlalchemy import Column, String, DateTime, Integer -from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker +from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker, declarative_base class Base(DeclarativeBase): pass +# class BaseModel: +# +# @classmethod +# def model_lookup_by_table_name(cls, table_name): +# registry_instance = getattr(cls, "registry") +# for mapper_ in registry_instance.mappers: +# model = mapper_.class_ +# model_class_name = model.__tablename__ +# if model_class_name == table_name: +# return model + +# Base = declarative_base(cls=BaseModel) \ No newline at end of file diff --git a/qt/database/comic.py b/qt/database/comic.py index dc4204f..49d222f 100644 --- a/qt/database/comic.py +++ b/qt/database/comic.py @@ -1,8 +1,8 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship -from database.base import Base +from .base import Base class Publisher(Base): @@ -106,7 +106,7 @@ class Artist(Base): comic_works = relationship("ComicWork") -class Worktype(Base): +class WorkType(Base): __tablename__ = "worktype" id = Column(String, primary_key=True) created_date = Column(DateTime) @@ -126,5 +126,5 @@ class ComicWork(Base): comic = relationship("Comic", back_populates="comic_works") artist_id = Column(String, ForeignKey("artist.id"), nullable=False) artist = relationship("Artist", back_populates="comic_works") - worktype_id = Column(String, ForeignKey("worktype.id"), nullable=False) - worktype = relationship("Worktype", back_populates="comic_works") + work_type_id = Column(String, ForeignKey("worktype.id"), nullable=False) + work_type = relationship("WorkType", back_populates="comic_works") diff --git a/qt/database/media.py b/qt/database/media.py index 621fdce..b5d793b 100644 --- a/qt/database/media.py +++ b/qt/database/media.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy.dialects.mysql import BIT from database.base import Base @@ -13,10 +13,10 @@ class MediaFile(Base): cloud_link = Column(String(255)) file_name = Column(String(255)) path = Column(String(255)) - review = Column(BIT(1), default=True) + review = Column(BIT(1)) title = Column(String(255)) url = Column(String(255)) - should_download = Column(Boolean, default=True) + should_download = Column(BIT(1)) def __repr__(self): return f'MediaFile({self.id} {self.title} {self.title})' diff --git a/qt/database/tysc.py b/qt/database/tysc.py index 9c94177..733d7ed 100644 --- a/qt/database/tysc.py +++ b/qt/database/tysc.py @@ -1,8 +1,8 @@ -from sqlalchemy import Boolean, Column, DateTime, Integer, String, ForeignKey, UniqueConstraint +from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, UniqueConstraint from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship -from database.base import Base +from .base import Base class Sport(Base): @@ -28,7 +28,7 @@ class Team(Base): name = Column(String(255), nullable=False, index=True, unique=True) short_name = Column(String(255), nullable=False, ) sport_id = Column(String, ForeignKey("sport.id"), nullable=False) - sport = relationship("Sport", back_populates="positions") + sport = relationship("Sport", back_populates="teams") roosters = relationship("Rooster") @@ -81,7 +81,7 @@ class Rooster(Base): player_id = Column(String, ForeignKey("player.id"), nullable=False, index=True) player = relationship("Player", back_populates="roosters") position_id = Column(String, ForeignKey("field_position.id"), nullable=False, index=True) - position = relationship("roosters") + position = relationship("FieldPosition", back_populates="roosters") cards = relationship("Card") class Vendor(Base): @@ -124,8 +124,8 @@ class Card(Base): card_number = Column(Integer, index=True) year = Column(Integer, index=True) card_set_id = Column(String, ForeignKey("card_set.id"), nullable=False) - card_set = relationship("cards") + card_set = relationship("CardSet", back_populates="cards") rooster_id = Column(String, ForeignKey("rooster.id"), nullable=False) - rooster = relationship("cards") + rooster = relationship("Rooster", back_populates="cards") vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False) vendor = relationship("Vendor", back_populates="cards") diff --git a/qt/gui/data_view.py b/qt/gui/data_view.py new file mode 100644 index 0000000..91c66d2 --- /dev/null +++ b/qt/gui/data_view.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class DataViewMeta(ABC): + @abstractmethod + def get_header(self): + pass + + +class ComicView(DataViewMeta): + def get_header(self): + pass diff --git a/qt/gui/data_view_model.py b/qt/gui/data_view_model.py new file mode 100644 index 0000000..4ffdc8c --- /dev/null +++ b/qt/gui/data_view_model.py @@ -0,0 +1,32 @@ +from typing import List + +from PySide6.QtCore import QModelIndex, QAbstractTableModel +from PySide6.QtGui import Qt + +from gui.data_view import DataViewMeta + + +class DataViewModel(QAbstractTableModel): + def __init__(self): + super().__init__() + self.main_window = None + self._config = None + self._data = List[DataViewMeta] + + def rowCount(self, parent=QModelIndex()): + return len(self._data) + + def columnCount(self, parent=QModelIndex()): + return 0 + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + return None + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + return None + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + return False + + def flags(self, index): + return None diff --git a/qt/gui/dialogs.py b/qt/gui/dialogs.py index 703baeb..21dbe92 100644 --- a/qt/gui/dialogs.py +++ b/qt/gui/dialogs.py @@ -1,7 +1,7 @@ from pathlib import Path from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QFileDialog, \ - QGroupBox, QCheckBox, QComboBox + QCheckBox, QComboBox class ExportKontorDialog(QDialog): @@ -10,7 +10,7 @@ class ExportKontorDialog(QDialog): self.parent = parent self.kontor_db = kontor_db - self.file_name = None + self.file_name = "data.json" self.tables = [] self._table_options = {} diff --git a/qt/gui/main_window.py b/qt/gui/main_window.py index 42b4383..f47abd5 100644 --- a/qt/gui/main_window.py +++ b/qt/gui/main_window.py @@ -3,6 +3,8 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidg from PySide6.QtWidgets import QLabel, QMainWindow from database import KontorDB +from database.media import MediaFile +from database.comic import Comic from gui.dialogs import ExportKontorDialog, ImportKontorDialog from gui.model_config import KontorModelConfig from gui.table_model import KontorTableModel @@ -33,8 +35,8 @@ class MainWindow(QMainWindow): parent_layout = QVBoxLayout() self.central_widget.setLayout(parent_layout) self.tabs = QTabWidget() - self.tabs.addTab(self.generate_data_tab("comic"), "Comics") - self.tabs.addTab(self.generate_data_tab("media_file"), "MediaFile") + self.tabs.addTab(self.generate_data_tab("comic", Comic), "Comics") + self.tabs.addTab(self.generate_data_tab("media_file", MediaFile), "MediaFile") self.tabs.currentChanged.connect(self._tab_changed) #label.setAlignment(Qt.AlignmentFlag.AlignCenter) parent_layout.addWidget(self.tabs) @@ -111,6 +113,7 @@ class MainWindow(QMainWindow): print(export_dlg.get_tables_to_export()) print(f"export DB to {export_dlg.file_name}") self.statusBar.showMessage(f"export DB to {export_dlg.file_name}", 3000) + self.kontor_db.export_db(export_dlg.current_export_type, export_dlg.file_name, export_dlg.get_tables_to_export()) else: self.statusBar.showMessage("Export cancelled", 3000) @@ -120,9 +123,9 @@ class MainWindow(QMainWindow): def _tab_changed(self, tab_index): self.data[tab_index].refresh() - def generate_data_tab(self, table_name): + def generate_data_tab(self, table_name, table): data_tab = QWidget() - table_config = KontorModelConfig(self.kontor_db, self, table_name) + table_config = KontorModelConfig(self.kontor_db, self, table_name, table) model = KontorTableModel(table_config) layout = QVBoxLayout() self.data.append(model) diff --git a/qt/gui/model_config.py b/qt/gui/model_config.py index c2644e9..a3f902d 100644 --- a/qt/gui/model_config.py +++ b/qt/gui/model_config.py @@ -6,25 +6,18 @@ from database import KontorDB class KontorModelConfig: - def __init__(self, kontor_db: KontorDB, main_window, table_name: str): + def __init__(self, kontor_db: KontorDB, main_window, table_name: str, table): self.header = {} self.filter = {} self.main_window = main_window - self._table = table_name - self._table_id = None + self._table_name = table_name + self._table = table self.kontor_db = kontor_db self.get_table_config() - def get_table_id(self): - if self._table_id is not None: - return - self._table_id = self.kontor_db.get_table_id(self._table) - def get_table_config(self): - if self._table_id is None: - self.get_table_id() - self.header = self.kontor_db.get_column_meta_data(self._table_id, self._table) - self.filter = self.kontor_db.get_filters(self._table_id) + self.header = self.kontor_db.get_column_meta_data(self._table_name) + self.filter = self.kontor_db.get_filters(self._table_name) def get_filter(self) -> str: filter_rule = "" @@ -41,8 +34,20 @@ class KontorModelConfig: # print(f"{filter_rule=}") return filter_rule + def filters(self) -> dict: + _filters = {} + # print(self.filter["download"].isChecked()) + for column, filter_info in self.filter.items(): + # print(column, filter_info) + if filter_info['widget'].isChecked(): + _filters[column] = True + # print(f"{filter_rule=}") + return _filters + def get_data(self) -> list: - data = self.kontor_db.get_data(self._table, self.header, self.get_filter()) + # data = self.kontor_db.get_data(self._table_name, self.header, self.get_filter()) + # data.clear() + data = self.kontor_db.data(self._table, self.header, self.filters()) # print(f"KontorModelConfig.get_data: {len(data)}") # comics = self.kontor_db.session.query(Comic).all() # print(f'{len(comics)} Comics loaded') diff --git a/qt/gui/table_model.py b/qt/gui/table_model.py index 5e80c3b..e1ba6ae 100644 --- a/qt/gui/table_model.py +++ b/qt/gui/table_model.py @@ -45,8 +45,8 @@ class KontorTableModel(QAbstractTableModel): if self._data is None: return None value = self._data[index.row()][index.column()] - if role == Qt.ItemDataRole.DisplayRole: - # print('{}: {}'.format(value, type(value))) + # print('{}:: {}:: {}: {}'.format(index, role, value, type(value))) + if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole: if isinstance(value, datetime): return value.strftime("%Y-%m-%d %M:%M:%S") if isinstance(value, str): @@ -57,6 +57,7 @@ class KontorTableModel(QAbstractTableModel): else: return self._main_window.cross if isinstance(value, int): + # print('{}:: {}: {}'.format(index, value, type(value))) if value == 1: return self._main_window.tick else: @@ -69,7 +70,6 @@ class KontorTableModel(QAbstractTableModel): return str(value) if role == Qt.ItemDataRole.DecorationRole: if isinstance(value, bytes): - # print('{}: {}'.format(value, type(value))) if value == b'\x01': return self._main_window.tick else: @@ -91,13 +91,18 @@ class KontorTableModel(QAbstractTableModel): # print(f"Header count: {len(self._config.get_header())}") return len(self._config.header) - def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + def setData(self, index, value, role: int) -> bool: + print(index, role) if role == Qt.ItemDataRole.EditRole: self._data[index.row()][index.column()] = value + print(self._data[index.row()][index.column()]) + self.dataChanged.emit(index, index) + return True if role == Qt.ItemDataRole.CheckStateRole: + print("role == Qt.ItemDataRole.CheckStateRole") checked = value == Qt.CheckState.Checked self._data[index.row()][index.column()] = checked - return True + return False def flags(self, index): return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsUserTristate diff --git a/qt/pysidedeploy.spec b/qt/pysidedeploy.spec old mode 100755 new mode 100644