From 3f65ec55fc72af46daab9a8eadab32ba813dc6f0 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Thu, 5 Dec 2024 20:40:13 +0100 Subject: [PATCH 01/26] Add Taskjuggler project file --- kontor.tjp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 kontor.tjp diff --git a/kontor.tjp b/kontor.tjp new file mode 100644 index 0000000..a5d46c6 --- /dev/null +++ b/kontor.tjp @@ -0,0 +1,30 @@ +project kontor "Kontor" "0.1.0" 2024-12-05 +5m { + timezone "Europe/Berlin" + timeformat "%d.%m.%Y" + numberformat "-" "" "" "," 1 + currencyformat "-" "" "" "," 0 + currency "EUR" + + scenario plan "Plan" { + scenario real "Realität" + } +} + +resource gcpce "Google Cloud Compute Engine" { + efficiency 0.0 + rate 0.25 +} + +task flask "Kontor-Flask" { + task import "Import repository kontor-flask into directory flask" +} +task springboot "Springboot Vaadin" { + task import "Import repository kontor-spring into directory springboot" +} + +taskreport "Arbeitsliste" { + formats html + hidetask ~isleaf() + sorttasks plan.end.up +} + From 57e7b9e999389275133d3323bfbb5e6df32c1832 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 14:10:15 +0100 Subject: [PATCH 02/26] first implementation to show Comics and MediaFiles --- .gitignore | 5 ++ gui/application-export.png | Bin 0 -> 513 bytes gui/application-import.png | Bin 0 -> 524 bytes gui/arrow-circle-double.png | Bin 0 -> 836 bytes gui/comic_model.py | 89 ++++++++++++++++++++++++ gui/cross.png | Bin 0 -> 544 bytes gui/kontor.py | 131 ++++++++++++++++++++++++++++++++++++ gui/media_file_model.py | 91 +++++++++++++++++++++++++ gui/resources.py | 7 ++ gui/tick.png | Bin 0 -> 634 bytes 10 files changed, 323 insertions(+) create mode 100644 .gitignore create mode 100644 gui/application-export.png create mode 100644 gui/application-import.png create mode 100644 gui/arrow-circle-double.png create mode 100644 gui/comic_model.py create mode 100644 gui/cross.png create mode 100644 gui/kontor.py create mode 100644 gui/media_file_model.py create mode 100644 gui/resources.py create mode 100644 gui/tick.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45f246d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.idea/ +bonus +icons +icons-shadowless diff --git a/gui/application-export.png b/gui/application-export.png new file mode 100644 index 0000000000000000000000000000000000000000..555887a28d64bc812c4dfa98a6ff1da1927b7792 GIT binary patch literal 513 zcmV+c0{;DpP)A0|fCeqz@KJVX8nnt2D9k2UrOajFq_SwR4OUbbUJ;HgeDQF66oE777mBiWdd2F(NKOO zk$Ax17hTui{#6jPXfz7dY87I!*bgRr4TVA=afmd=7~nkYBq$b(su#^>Q?2y;%rJ~= zA;j4sgM^B|5YOb(M4cc`5q!^h_wPg5is0Dq{42l!6{r`}{QE*r00000NkvXXu0mjf Djqud07&@TR)18#v5Hqj8@|Bnpn>z;V`CueWd< z7oI05i2^A#D9Qz9v#hs`;>*D7{eB7>0ppH2SLstJO-8BwR~M`A7kF ztyTlI66tgrtyT-MSPa~yU>3AuKR54&OXJih@;oco-=1sDLQa}`@LC7Iy> O0000dz zNLw#yfl;S$OS+NxP!T0`q27a_KcR=-dJ5{Xm+WEf!J>k|BqE5#C}ImGwQ$$<(wdh& z>AIJ5zVFx9lo8J{@aWY%oIP6CAZKYHol)uRf|554m_YTnpAlGl zL9{5M4)qVt#u>|mLQVrh46q|<)MP|~t8EE-8crSv>~io{4+abW2A~lV0z>OM)mbBD zmWtqV;+?9YGlhpQQ@M9zi{*KRG_T5PQ_nvC9tMo_mT6XEIU5;J+S?d(HD_0f!1ES7 z7@Uj?4!kD>&?r8e&c1jan^9Cs!}3z0d7Izafj)-}rbYZR}EdihS6`M#Fcy= zaw{u#mV;BNWe3{Sqiq;{_M>cOP*8MhP1CLQf3t|C2tpM55ycYh7RL%}Yf+Bp#e!~C z2(WG^ZnlxC>igv!sW2GM8SslVJ(oFwkp~5*6E^0rT$Q zya}^2lB>ITrX0zGEa!lc90$HC4~#^g#%ZB2j3vzHhi;TO$n{?Wwx(^0>^QA_x~H^z zTf=2SxY0rCf|fl@rb7TXYr%pE(0=u@G literal 0 HcmV?d00001 diff --git a/gui/comic_model.py b/gui/comic_model.py new file mode 100644 index 0000000..2472670 --- /dev/null +++ b/gui/comic_model.py @@ -0,0 +1,89 @@ +from datetime import datetime + +import mariadb +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt +from PySide6.QtGui import QColor + + +class ComicTableModel(QAbstractTableModel): + + def __init__(self, db_config, main_window): + super().__init__() + self.main_window = main_window + self._data = [] + self.status_bar = main_window.statusBar + self.mariadb_conn = mariadb.connect( + host=db_config['mariadb']['host'], + port=db_config['mariadb']['port'], + user=db_config['mariadb']['user'], + password=db_config['mariadb']['password'], + database=db_config['mariadb']['database'] + ) + self.refresh() + + def refresh(self): + data = [] + cursor = self.mariadb_conn.cursor() + cursor.execute("SELECT id, created_date, last_modified_date, title, publisher_id FROM comic") + rows = cursor.fetchall() + for row in rows: + data.append(list(row)) + self.status_bar.showMessage(f"{len(rows)} Einträge geladen", 3000) + self._data = data + + def rowCount(self, parent=QModelIndex()): + # The length of the outer list. + return len(self._data) + + def headerData(self, col, orientation, role=Qt.ItemDataRole.DisplayRole): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + match col: + case 0: + return "ID" + case 1: + return "Created" + case 2: + return "Updated" + case 3: + return "Title" + case 4: + return "Verlag" + if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole: + return str(col + 1) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + value = self._data[index.row()][index.column()] + if role == Qt.ItemDataRole.DisplayRole: + 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 "True" + return "False" + return 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: + 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) + return len(self._data[0]) + + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): + if role == Qt.ItemDataRole.EditRole: + self._data[index.row()][index.column()] = value + if role == Qt.ItemDataRole.CheckStateRole: + checked = value == Qt.CheckState.Checked + self._data[index.row()][index.column()] = checked + return True + + def flags(self, index): + return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsUserTristate diff --git a/gui/cross.png b/gui/cross.png new file mode 100644 index 0000000000000000000000000000000000000000..6b9fa6dd36ee8165272a13dd263f573507c78ca6 GIT binary patch literal 544 zcmV+*0^j|KP)L-ku(! z6_?D-?!0+#=VtbVZQGdTnZt~apI_HPK#=zVadHM((E`e*C+RoFb)Qo8-U{Lb7{`Tz zw4B8FZ|o?Wox+p=1wp47C;7ar*Xu~;a?<=sjPv>+otDjJ6FaGt!YnNyxQQhp)G1V! zk;r6ZtyV)c8pTbiRAnHRNXTxti%=+p$4aG2*+mMM&xrdiU^`VPk^N*+HX98^7!HT% zwA%;A{T)GxeQ|RcxyzV%! z$AbYD+)i1R64zB?q}NmTz}5|04~J#YG_goA*Eq(QJvp7pG14hUj1pZ^t>3S*xqHUO ze~r;}zTY_XkZ*}d@gm!;N90h8nBQenBUZ_ukZKNicn*hc_Pmc!Jn|2=s<~}>!Sb>Qm3!QP46b_JFw5YU70Tu$X}=T}ez@@t%j iG9vD)nDux55?}x$+|UyQVK_bj0000tYd4K$mX5uyr2F@fdffp{&DSHtl4|Bn9=ukpCx`#%PTUr-D(@b7;C zhJXJjrnw{;1KBM=6&|E`fsNrGL!Y6%zUh}QUl`(@V)PmQFtotEKmafTHP0uP}7&%m4pcKQ#X)BiAJ2y+PrtBI>9eEIt2-_c7)?*Lsf z5vX5*Af@m-wBJRV%#Fiz=BdPr0!2^a2d-yv7gSU);Z6{`~Oy?E5qSnHUln4*Y%!){F!Y1~5WXoC44g zb7l_)X~q^V6MmWR7e3wj|L|Wb!|^}Y86N$^h+kv_Kw-fP!~#If$6(B4$)LlK#&G6; zC&ShMSAb$5tA9Xg5dOsg@-z^@3;;QS9f&!gG&3|;{Da~@Q2ZB({s%XJ5&#fj0J|In U+D>(nEdT%j07*qoM6N<$g0@&8X#fBK literal 0 HcmV?d00001 From 9dcc09c586982a072bfe57eca102ed01f19abde4 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 14:13:15 +0100 Subject: [PATCH 03/26] display tick and cross for boolean values --- gui/media_file_model.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gui/media_file_model.py b/gui/media_file_model.py index 619ce2e..f06d94f 100644 --- a/gui/media_file_model.py +++ b/gui/media_file_model.py @@ -60,11 +60,11 @@ class MediaFileTableModel(QAbstractTableModel): 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, bytes): + if value == b'\x01': + return self.main_window.tick + else: + return self.main_window.cross return value if role == Qt.ItemDataRole.DecorationRole: if isinstance(value, bytes): From d98dc79cf8326f43daccae5f692e0cd163c4a844 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 21:47:27 +0100 Subject: [PATCH 04/26] refresh table when updated --- gui/kontor.py | 27 ++++++++++++++----- gui/media_file_model.py | 36 +++++++++++++++++++++----- gui/{ => res}/application-export.png | Bin gui/{ => res}/application-import.png | Bin gui/{ => res}/arrow-circle-double.png | Bin gui/{ => res}/cross.png | Bin gui/{ => res}/tick.png | Bin 7 files changed, 50 insertions(+), 13 deletions(-) rename gui/{ => res}/application-export.png (100%) rename gui/{ => res}/application-import.png (100%) rename gui/{ => res}/arrow-circle-double.png (100%) rename gui/{ => res}/cross.png (100%) rename gui/{ => res}/tick.png (100%) diff --git a/gui/kontor.py b/gui/kontor.py index a435fe6..be15c66 100644 --- a/gui/kontor.py +++ b/gui/kontor.py @@ -6,8 +6,7 @@ from pathlib import Path import yaml from PySide6.QtGui import QAction, QIcon -from PySide6.QtSql import QSqlDatabase -from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidget, QTableView +from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidget, QTableView, QHBoxLayout, QCheckBox from PySide6.QtWidgets import QApplication, QLabel, QMainWindow from platformdirs import PlatformDirs @@ -21,11 +20,11 @@ class MainWindow(QMainWindow): def __init__(self, config): super().__init__() - self.tick = QIcon('tick.png') - self.cross = QIcon('cross.png') - self.import_icon = QIcon("application-import.png") - self.export_icon = QIcon("application-export.png") - self.circle_icon = QIcon("arrow-circle-double.png") + self.tick = QIcon('res/tick.png') + self.cross = QIcon('res/cross.png') + self.import_icon = QIcon("res/application-import.png") + self.export_icon = QIcon("res/application-export.png") + self.circle_icon = QIcon("res/arrow-circle-double.png") self.setWindowTitle("Kontor") self.setMinimumSize(800, 500) @@ -35,6 +34,7 @@ class MainWindow(QMainWindow): self._createStatusBar() self.data = [] + self.filter = {} self.central_widget = QWidget() parent_layout = QVBoxLayout() self.central_widget.setLayout(parent_layout) @@ -111,11 +111,24 @@ class MainWindow(QMainWindow): def generate_tab_media_file(self, db_configuration): media_file_tab = QWidget() layout = QVBoxLayout() + filter_layout = QHBoxLayout() + download_checkbox = QCheckBox() + download_checkbox.setText("Download") + download_checkbox.checkStateChanged.connect(self.refresh) + self.filter["download"] = download_checkbox + review_checkbox = QCheckBox() + review_checkbox.setText("Review") + review_checkbox.checkStateChanged.connect(self.refresh) + self.filter["review"] = review_checkbox + filter_layout.addWidget(review_checkbox) + filter_layout.addWidget(download_checkbox) + filter_layout.addStretch() model = MediaFileTableModel(db_configuration, self) self.data.append(model) media_file_tab.setLayout(layout) table_view = QTableView() table_view.setModel(model) + layout.addLayout(filter_layout) layout.addWidget(table_view) return media_file_tab diff --git a/gui/media_file_model.py b/gui/media_file_model.py index f06d94f..e34ad26 100644 --- a/gui/media_file_model.py +++ b/gui/media_file_model.py @@ -11,7 +11,6 @@ class MediaFileTableModel(QAbstractTableModel): super().__init__() self.main_window = main_window self._data = [] - self.status_bar = main_window.statusBar self.mariadb_conn = mariadb.connect( host=db_config['mariadb']['host'], port=db_config['mariadb']['port'], @@ -24,12 +23,37 @@ class MediaFileTableModel(QAbstractTableModel): def refresh(self): data = [] cursor = self.mariadb_conn.cursor() - cursor.execute("SELECT id, url, review, should_download, file_name, cloud_link FROM media_file") + filter_rule = "" + print(self.main_window.filter["download"].isChecked()) + if self.main_window.filter["download"].isChecked(): + print(self.main_window.filter["download"].isChecked()) + filter_rule = "WHERE should_download is true" + if self.main_window.filter["review"].isChecked(): + if len(filter_rule) > 0: + filter_rule += " AND " + else: + filter_rule += "WHERE " + filter_rule += "review is true" + print(f"{filter_rule=}") + try: + cursor.execute(f"SELECT id, url, review, should_download, file_name, cloud_link FROM media_file {filter_rule}") + except mariadb.Error as error: + print(error) rows = cursor.fetchall() - for row in rows: - data.append(list(row)) - self.status_bar.showMessage(f"{len(rows)} Einträge geladen", 3000) - self._data = data + print(len(rows)) + if len(rows) > 0: + self.beginResetModel() + for row in rows: + data.append(list(row)) + self._data = data + self.endResetModel() + else: + self._data = None + try: + self.layoutChanged.emit() + except: + + self.main_window.statusBar.showMessage(f"{len(rows)} Einträge geladen", 3000) def rowCount(self, parent=QModelIndex()): # The length of the outer list. diff --git a/gui/application-export.png b/gui/res/application-export.png similarity index 100% rename from gui/application-export.png rename to gui/res/application-export.png diff --git a/gui/application-import.png b/gui/res/application-import.png similarity index 100% rename from gui/application-import.png rename to gui/res/application-import.png diff --git a/gui/arrow-circle-double.png b/gui/res/arrow-circle-double.png similarity index 100% rename from gui/arrow-circle-double.png rename to gui/res/arrow-circle-double.png diff --git a/gui/cross.png b/gui/res/cross.png similarity index 100% rename from gui/cross.png rename to gui/res/cross.png diff --git a/gui/tick.png b/gui/res/tick.png similarity index 100% rename from gui/tick.png rename to gui/res/tick.png From c6d1e4d7e7e64c3eff2f0473b93a87230690110d Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 23:44:29 +0100 Subject: [PATCH 05/26] fix errors when displaying empty table --- gui/media_file_model.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gui/media_file_model.py b/gui/media_file_model.py index e34ad26..e5af2a4 100644 --- a/gui/media_file_model.py +++ b/gui/media_file_model.py @@ -35,10 +35,7 @@ class MediaFileTableModel(QAbstractTableModel): filter_rule += "WHERE " filter_rule += "review is true" print(f"{filter_rule=}") - try: - cursor.execute(f"SELECT id, url, review, should_download, file_name, cloud_link FROM media_file {filter_rule}") - except mariadb.Error as error: - print(error) + cursor.execute(f"SELECT id, url, review, should_download, file_name, cloud_link FROM media_file {filter_rule}") rows = cursor.fetchall() print(len(rows)) if len(rows) > 0: @@ -49,14 +46,13 @@ class MediaFileTableModel(QAbstractTableModel): self.endResetModel() else: self._data = None - try: - self.layoutChanged.emit() - except: - + self.layoutChanged.emit() self.main_window.statusBar.showMessage(f"{len(rows)} 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): @@ -78,6 +74,8 @@ class MediaFileTableModel(QAbstractTableModel): 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()] if role == Qt.ItemDataRole.DisplayRole: if isinstance(value, datetime): @@ -101,6 +99,8 @@ class MediaFileTableModel(QAbstractTableModel): 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) + if self._data is None: + return 5 return len(self._data[0]) def setData(self, index, value, role=Qt.ItemDataRole.EditRole): From 3aed8af868e07657d5dade309a0b9a147486c73b Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 6 Jan 2025 17:07:20 +0100 Subject: [PATCH 06/26] implement generic table model implement generic table model which reads table info from db and constructs table view --- gui/kontor.py | 40 ++++------ gui/model_config.py | 82 +++++++++++++++++++++ gui/{media_file_model.py => table_model.py} | 71 +++++------------- 3 files changed, 117 insertions(+), 76 deletions(-) create mode 100644 gui/model_config.py rename gui/{media_file_model.py => table_model.py} (52%) diff --git a/gui/kontor.py b/gui/kontor.py index be15c66..3b601ad 100644 --- a/gui/kontor.py +++ b/gui/kontor.py @@ -6,17 +6,17 @@ from pathlib import Path import yaml from PySide6.QtGui import QAction, QIcon -from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidget, QTableView, QHBoxLayout, QCheckBox +from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidget, QTableView from PySide6.QtWidgets import QApplication, QLabel, QMainWindow from platformdirs import PlatformDirs from comic_model import ComicTableModel -from media_file_model import MediaFileTableModel +from model_config import KontorModelConfig +from table_model import KontorTableModel class MainWindow(QMainWindow): - def __init__(self, config): super().__init__() @@ -28,10 +28,10 @@ class MainWindow(QMainWindow): self.setWindowTitle("Kontor") self.setMinimumSize(800, 500) - self._createActions() - self._createMenuBar() - self._createToolBars() - self._createStatusBar() + self._create_actions() + self._create_menubar() + self._create_toolbars() + self._create_statusbar() self.data = [] self.filter = {} @@ -47,7 +47,7 @@ class MainWindow(QMainWindow): self.setCentralWidget(self.central_widget) - def _createActions(self): + def _create_actions(self): self.newAction = QAction("&New", self) self.aboutAction = QAction("&Über...", self) self.aboutAction.triggered.connect(self.about) @@ -59,7 +59,7 @@ class MainWindow(QMainWindow): self.exitAction.setShortcut("Alt+F4") self.exitAction.triggered.connect(self.close) - def _createMenuBar(self): + def _create_menubar(self): menu_bar = self.menuBar() # File menu file_menu = QMenu("&Datei") @@ -75,14 +75,14 @@ class MainWindow(QMainWindow): menu_bar.addMenu(help_menu) help_menu.addAction(self.aboutAction) - def _createToolBars(self): + 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 _createStatusBar(self): + def _create_statusbar(self): self.statusBar = self.statusBar() self.statusBar.showMessage("Kontor ready", 6000) self.status_label = QLabel("") @@ -110,25 +110,15 @@ class MainWindow(QMainWindow): def generate_tab_media_file(self, db_configuration): media_file_tab = QWidget() + table_config = KontorModelConfig(db_configuration, self, "media_file") + model = KontorTableModel(table_config) layout = QVBoxLayout() - filter_layout = QHBoxLayout() - download_checkbox = QCheckBox() - download_checkbox.setText("Download") - download_checkbox.checkStateChanged.connect(self.refresh) - self.filter["download"] = download_checkbox - review_checkbox = QCheckBox() - review_checkbox.setText("Review") - review_checkbox.checkStateChanged.connect(self.refresh) - self.filter["review"] = review_checkbox - filter_layout.addWidget(review_checkbox) - filter_layout.addWidget(download_checkbox) - filter_layout.addStretch() - model = MediaFileTableModel(db_configuration, self) + # model = MediaFileTableModel(db_configuration, self) self.data.append(model) media_file_tab.setLayout(layout) table_view = QTableView() table_view.setModel(model) - layout.addLayout(filter_layout) + layout.addLayout(table_config.get_filter_layout()) layout.addWidget(table_view) return media_file_tab diff --git a/gui/model_config.py b/gui/model_config.py new file mode 100644 index 0000000..1bea8a5 --- /dev/null +++ b/gui/model_config.py @@ -0,0 +1,82 @@ +import mariadb +from PySide6.QtWidgets import QHBoxLayout, QCheckBox + + +class KontorModelConfig: + + def __init__(self, db_config, main_window, table_name: str): + self.header = [] + self.filter = {} + self.main_window = main_window + self._table = table_name + self.db_conn = mariadb.connect( + host=db_config['mariadb']['host'], + port=db_config['mariadb']['port'], + user=db_config['mariadb']['user'], + password=db_config['mariadb']['password'], + database=db_config['mariadb']['database'] + ) + self.get_table_config() + + def get_table_id(self): + cursor = self.db_conn.cursor() + cursor.execute("SELECT id, created_date, last_modified_date FROM meta_data_table WHERE table_name=?", (self._table, )) + rows = cursor.fetchall() + if len(rows) == 1: + return rows[0][0] + return None + + def get_table_config(self): + table_id = self.get_table_id() + cursor = self.db_conn.cursor() + cursor.execute("SELECT id, column_name, column_order FROM meta_data_column WHERE table_id=? AND is_shown is true", (table_id, )) + rows = cursor.fetchall() + self.header.clear() + for (column_id, column_name, column_order) in rows: + self.header.insert(column_order-1, column_name) + print(f"retrieved {len(rows)} columns, set {len(self.header)} headers") + + def get_header(self) -> list: + self.get_table_config() + return self.header + + def get_filter(self) -> str: + filter_rule = "" + # print(self.filter["download"].isChecked()) + if self.filter["download"].isChecked(): + # print(self.filter["download"].isChecked()) + filter_rule = "WHERE should_download is true" + if self.filter["review"].isChecked(): + if len(filter_rule) > 0: + filter_rule += " AND " + else: + filter_rule += "WHERE " + filter_rule += "review is true" + print(f"{filter_rule=}") + return filter_rule + + def get_statement(self) -> str: + filter_rule = self.get_filter() + self.get_table_config() + columns = "" + for index in range(len(self.header)): + if index > 0: + columns += ", " + columns += self.header[index] + statement = f"SELECT {columns} FROM media_file {filter_rule}" + return statement + + def get_filter_layout(self) -> QHBoxLayout: + filter_layout = QHBoxLayout() + download_checkbox = QCheckBox() + download_checkbox.setText("Download") + download_checkbox.checkStateChanged.connect(self.main_window.refresh) + self.filter["download"] = download_checkbox + review_checkbox = QCheckBox() + review_checkbox.setText("Review") + review_checkbox.checkStateChanged.connect(self.main_window.refresh) + self.filter["review"] = review_checkbox + filter_layout.addWidget(review_checkbox) + filter_layout.addWidget(download_checkbox) + filter_layout.addStretch() + return filter_layout diff --git a/gui/media_file_model.py b/gui/table_model.py similarity index 52% rename from gui/media_file_model.py rename to gui/table_model.py index e5af2a4..5fc5dc9 100644 --- a/gui/media_file_model.py +++ b/gui/table_model.py @@ -1,41 +1,23 @@ from datetime import datetime -import mariadb -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt -from PySide6.QtGui import QColor +from PySide6.QtCore import QAbstractTableModel, QModelIndex +from PySide6.QtGui import Qt + +from model_config import KontorModelConfig -class MediaFileTableModel(QAbstractTableModel): +class KontorTableModel(QAbstractTableModel): - def __init__(self, db_config, main_window): + def __init__(self, model_config: KontorModelConfig): super().__init__() - self.main_window = main_window - self._data = [] - self.mariadb_conn = mariadb.connect( - host=db_config['mariadb']['host'], - port=db_config['mariadb']['port'], - user=db_config['mariadb']['user'], - password=db_config['mariadb']['password'], - database=db_config['mariadb']['database'] - ) - self.refresh() + self._main_window = model_config.main_window + self._config = model_config + self._data = None def refresh(self): data = [] - cursor = self.mariadb_conn.cursor() - filter_rule = "" - print(self.main_window.filter["download"].isChecked()) - if self.main_window.filter["download"].isChecked(): - print(self.main_window.filter["download"].isChecked()) - filter_rule = "WHERE should_download is true" - if self.main_window.filter["review"].isChecked(): - if len(filter_rule) > 0: - filter_rule += " AND " - else: - filter_rule += "WHERE " - filter_rule += "review is true" - print(f"{filter_rule=}") - cursor.execute(f"SELECT id, url, review, should_download, file_name, cloud_link FROM media_file {filter_rule}") + cursor = self._config.db_conn.cursor() + cursor.execute(self._config.get_statement()) rows = cursor.fetchall() print(len(rows)) if len(rows) > 0: @@ -46,8 +28,8 @@ class MediaFileTableModel(QAbstractTableModel): self.endResetModel() else: self._data = None - self.layoutChanged.emit() - self.main_window.statusBar.showMessage(f"{len(rows)} Einträge geladen", 3000) + self.layoutChanged.emit() + self._main_window.statusBar.showMessage(f"{len(rows)} Einträge geladen", 3000) def rowCount(self, parent=QModelIndex()): # The length of the outer list. @@ -57,19 +39,7 @@ class MediaFileTableModel(QAbstractTableModel): def headerData(self, col, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - match col: - case 0: - return "ID" - case 1: - return "URL" - case 2: - return "Review" - case 3: - return "Download" - case 4: - return "Filename" - case 5: - return "Cloud Link" + return self._config.header[col] if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole: return str(col+1) @@ -84,24 +54,23 @@ class MediaFileTableModel(QAbstractTableModel): return value if isinstance(value, bytes): if value == b'\x01': - return self.main_window.tick + return self._main_window.tick else: - return self.main_window.cross + return self._main_window.cross return value if role == Qt.ItemDataRole.DecorationRole: if isinstance(value, bytes): # print('{}: {}'.format(value, type(value))) if value == b'\x01': - return self.main_window.tick + return self._main_window.tick else: - return self.main_window.cross + 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) - if self._data is None: - return 5 - return len(self._data[0]) + print(f"Header count: {len(self._config.get_header())}") + return len(self._config.get_header()) def setData(self, index, value, role=Qt.ItemDataRole.EditRole): if role == Qt.ItemDataRole.EditRole: From 78632e0e12a6264058c164be6851b8db886160ed Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 6 Jan 2025 17:09:38 +0000 Subject: [PATCH 07/26] Import kontor-spring into directory springboot --- .gitignore | 8 +- springboot/.gitattributes | 9 + springboot/.gitignore | 32 + springboot/README.md | 3 + springboot/build.gradle | 240 ++++ springboot/frontend/themes/kontor/styles.css | 10 + springboot/frontend/themes/kontor/theme.json | 3 + springboot/gradle.properties | 3 + springboot/gradle/libs.versions.toml | 58 + springboot/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + springboot/gradlew | 249 ++++ springboot/gradlew.bat | 92 ++ springboot/settings.gradle | 24 + .../src/docs/asciidoc/kontor-spring.adoc | 509 +++++++ .../kontor/comics/views/ArtistViewTest.java | 41 + .../kontor/comics/views/ArtistformTest.java | 63 + .../kontor/comics/views/ComicViewTest.java | 43 + .../comics/views/ComicWorkViewTest.java | 43 + .../kontor/comics/views/IssueViewTest.java | 43 + .../comics/views/PublisherViewTest.java | 41 + .../kontor/comics/views/StoryArcViewTest.java | 43 + .../comics/views/TradePaperbackViewTest.java | 47 + .../kontor/comics/views/VolumeViewTest.java | 50 + .../kontor/comics/views/WorktypeViewTest.java | 49 + .../kontor/tysc/views/CardSetViewTest.java | 43 + .../kontor/tysc/views/CardViewTest.java | 43 + .../tysc/views/FieldPositionViewTest.java | 43 + .../kontor/tysc/views/PlayerViewTest.java | 44 + .../kontor/tysc/views/RoosterViewTest.java | 43 + .../kontor/tysc/views/SportViewTest.java | 43 + .../kontor/tysc/views/TeamViewTest.java | 43 + .../kontor/tysc/views/VendorViewTest.java | 43 + .../resources/application.properties | 30 + .../java/de/thpeetz/kontor/Application.java | 24 + .../thpeetz/kontor/admin/AdminConstants.java | 53 + .../thpeetz/kontor/admin/MailProperties.java | 78 ++ .../kontor/admin/SetupModuleAdmin.java | 336 +++++ .../admin/data/AuthorizationMatrix.java | 35 + .../data/AuthorizationMatrixRepository.java | 13 + .../admin/data/MailAccountRepository.java | 8 + .../kontor/admin/data/MetaDataColumn.java | 37 + .../admin/data/MetaDataColumnRepository.java | 10 + .../kontor/admin/data/MetaDataTable.java | 26 + .../admin/data/MetaDataTableRepository.java | 8 + .../thpeetz/kontor/admin/data/ModuleData.java | 25 + .../admin/data/ModuleDataRepository.java | 15 + .../de/thpeetz/kontor/admin/data/Role.java | 30 + .../kontor/admin/data/RoleRepository.java | 18 + .../de/thpeetz/kontor/admin/data/User.java | 62 + .../kontor/admin/data/UserRepository.java | 16 + .../kontor/admin/services/AdminService.java | 110 ++ .../services/KontorUserDetailsService.java | 125 ++ .../kontor/admin/services/MailService.java | 24 + .../admin/services/MetaDataService.java | 101 ++ .../kontor/admin/services/ModuleService.java | 76 ++ .../kontor/admin/views/AdminLayout.java | 36 + .../kontor/admin/views/AuthorizationForm.java | 112 ++ .../kontor/admin/views/AuthorizationView.java | 114 ++ .../thpeetz/kontor/admin/views/LoginView.java | 51 + .../kontor/admin/views/MetaDataForm.java | 113 ++ .../kontor/admin/views/MetaDataView.java | 112 ++ .../kontor/admin/views/ModuleDataForm.java | 100 ++ .../kontor/admin/views/ModuleDataView.java | 118 ++ .../thpeetz/kontor/admin/views/RoleForm.java | 102 ++ .../thpeetz/kontor/admin/views/RoleView.java | 118 ++ .../thpeetz/kontor/admin/views/UserForm.java | 143 ++ .../kontor/admin/views/UserProfileView.java | 73 + .../thpeetz/kontor/admin/views/UserView.java | 135 ++ .../kontor/bookshelf/BookshelfConstants.java | 53 + .../bookshelf/SetupModuleBookshelf.java | 82 ++ .../kontor/bookshelf/data/Article.java | 27 + .../kontor/bookshelf/data/ArticleAuthor.java | 29 + .../data/ArticleAuthorRepository.java | 12 + .../bookshelf/data/ArticleRepository.java | 16 + .../thpeetz/kontor/bookshelf/data/Author.java | 44 + .../bookshelf/data/AuthorRepository.java | 18 + .../thpeetz/kontor/bookshelf/data/Book.java | 42 + .../kontor/bookshelf/data/BookAuthor.java | 29 + .../bookshelf/data/BookAuthorRepository.java | 12 + .../kontor/bookshelf/data/BookRepository.java | 21 + .../bookshelf/data/BookshelfPublisher.java | 32 + .../data/BookshelfPublisherRepository.java | 18 + .../bookshelf/services/BookshelfService.java | 118 ++ .../kontor/bookshelf/views/ArticleForm.java | 106 ++ .../kontor/bookshelf/views/ArticleView.java | 126 ++ .../kontor/bookshelf/views/AuthorForm.java | 117 ++ .../kontor/bookshelf/views/AuthorView.java | 132 ++ .../kontor/bookshelf/views/BookForm.java | 111 ++ .../kontor/bookshelf/views/BookView.java | 124 ++ .../bookshelf/views/BookshelfLayout.java | 34 + .../views/BookshelfPublisherView.java | 131 ++ .../kontor/bookshelf/views/PublisherForm.java | 108 ++ .../thpeetz/kontor/comics/ComicConstants.java | 93 ++ .../kontor/comics/SetupModuleComics.java | 1198 +++++++++++++++++ .../de/thpeetz/kontor/comics/data/Artist.java | 44 + .../kontor/comics/data/ArtistRepository.java | 17 + .../de/thpeetz/kontor/comics/data/Comic.java | 77 ++ .../kontor/comics/data/ComicRepository.java | 19 + .../thpeetz/kontor/comics/data/ComicWork.java | 48 + .../comics/data/ComicWorkRepository.java | 15 + .../de/thpeetz/kontor/comics/data/Issue.java | 42 + .../kontor/comics/data/IssueRepository.java | 17 + .../thpeetz/kontor/comics/data/Publisher.java | 40 + .../comics/data/PublisherRepository.java | 17 + .../thpeetz/kontor/comics/data/StoryArc.java | 31 + .../comics/data/StoryArcRepository.java | 17 + .../kontor/comics/data/TradePaperback.java | 32 + .../comics/data/TradePaperbackRepository.java | 20 + .../de/thpeetz/kontor/comics/data/Volume.java | 42 + .../kontor/comics/data/VolumeRepository.java | 13 + .../thpeetz/kontor/comics/data/Worktype.java | 44 + .../comics/data/WorktypeRepository.java | 18 + .../kontor/comics/services/ComicService.java | 299 ++++ .../kontor/comics/views/ArtistForm.java | 117 ++ .../kontor/comics/views/ArtistView.java | 129 ++ .../kontor/comics/views/ComicForm.java | 130 ++ .../kontor/comics/views/ComicLayout.java | 43 + .../kontor/comics/views/ComicView.java | 139 ++ .../kontor/comics/views/ComicWorkForm.java | 113 ++ .../kontor/comics/views/ComicWorkView.java | 122 ++ .../kontor/comics/views/IssueForm.java | 113 ++ .../kontor/comics/views/IssueView.java | 130 ++ .../kontor/comics/views/PublisherForm.java | 100 ++ .../kontor/comics/views/PublisherView.java | 130 ++ .../kontor/comics/views/StoryArcForm.java | 108 ++ .../kontor/comics/views/StoryArcView.java | 130 ++ .../comics/views/TradePaperBackForm.java | 109 ++ .../comics/views/TradePaperbackView.java | 129 ++ .../kontor/comics/views/VolumeForm.java | 109 ++ .../kontor/comics/views/VolumeView.java | 130 ++ .../kontor/comics/views/WorktypeForm.java | 121 ++ .../kontor/comics/views/WorktypeView.java | 130 ++ .../kontor/common/data/AbstractEntity.java | 34 + .../common/data/AbstractLinkEntity.java | 55 + .../kontor/common/views/AvatarMenuBar.java | 52 + .../kontor/common/views/KontorLayoutUtil.java | 100 ++ .../kontor/common/views/MainLayout.java | 101 ++ .../thpeetz/kontor/common/views/MainView.java | 18 + .../common/views/SeparateMainLayout.java | 31 + .../thpeetz/kontor/mailclient/data/Mail.java | 20 + .../kontor/mailclient/data/MailAccount.java | 29 + .../kontor/mailclient/views/EmailView.java | 222 +++ .../thpeetz/kontor/media/MediaConstants.java | 35 + .../kontor/media/SetupModuleMedia.java | 39 + .../kontor/media/data/MediaArticle.java | 29 + .../media/data/MediaArticleRepository.java | 13 + .../thpeetz/kontor/media/data/MediaFile.java | 40 + .../media/data/MediaFileRepository.java | 14 + .../thpeetz/kontor/media/data/MediaLink.java | 40 + .../thpeetz/kontor/media/data/MediaVideo.java | 40 + .../media/data/MediaVideoRepository.java | 15 + .../media/services/MediaArticleService.java | 42 + .../media/services/MediaFileService.java | 42 + .../media/services/MediaVideoService.java | 39 + .../kontor/media/views/MediaArticleForm.java | 108 ++ .../kontor/media/views/MediaArticleView.java | 171 +++ .../kontor/media/views/MediaFileForm.java | 113 ++ .../kontor/media/views/MediaFileView.java | 186 +++ .../kontor/media/views/MediaVideoForm.java | 113 ++ .../kontor/media/views/MediaVideoView.java | 178 +++ .../kontor/security/SecurityConfig.java | 59 + .../kontor/security/SecurityService.java | 105 ++ .../thpeetz/kontor/tysc/SetupModuleTysc.java | 519 +++++++ .../de/thpeetz/kontor/tysc/TyscConstants.java | 88 ++ .../de/thpeetz/kontor/tysc/data/Card.java | 49 + .../kontor/tysc/data/CardRepository.java | 17 + .../de/thpeetz/kontor/tysc/data/CardSet.java | 41 + .../kontor/tysc/data/CardSetRepository.java | 18 + .../kontor/tysc/data/FieldPosition.java | 52 + .../tysc/data/FieldPositionRepository.java | 22 + .../de/thpeetz/kontor/tysc/data/Player.java | 49 + .../kontor/tysc/data/PlayerRepository.java | 17 + .../de/thpeetz/kontor/tysc/data/Rooster.java | 51 + .../kontor/tysc/data/RoosterRepository.java | 11 + .../de/thpeetz/kontor/tysc/data/Sport.java | 42 + .../kontor/tysc/data/SportRepository.java | 17 + .../de/thpeetz/kontor/tysc/data/Team.java | 50 + .../kontor/tysc/data/TeamRepository.java | 27 + .../de/thpeetz/kontor/tysc/data/Vendor.java | 38 + .../kontor/tysc/data/VendorRepository.java | 17 + .../kontor/tysc/services/CardService.java | 93 ++ .../kontor/tysc/services/SportService.java | 147 ++ .../thpeetz/kontor/tysc/views/CardForm.java | 110 ++ .../kontor/tysc/views/CardSetForm.java | 103 ++ .../kontor/tysc/views/CardSetView.java | 129 ++ .../thpeetz/kontor/tysc/views/CardView.java | 129 ++ .../thpeetz/kontor/tysc/views/PlayerForm.java | 116 ++ .../thpeetz/kontor/tysc/views/PlayerView.java | 130 ++ .../kontor/tysc/views/PositionForm.java | 115 ++ .../kontor/tysc/views/PositionView.java | 130 ++ .../kontor/tysc/views/RoosterForm.java | 117 ++ .../kontor/tysc/views/RoosterView.java | 121 ++ .../thpeetz/kontor/tysc/views/SportForm.java | 103 ++ .../thpeetz/kontor/tysc/views/SportView.java | 129 ++ .../thpeetz/kontor/tysc/views/TeamForm.java | 115 ++ .../thpeetz/kontor/tysc/views/TeamView.java | 130 ++ .../thpeetz/kontor/tysc/views/TyscLayout.java | 41 + .../thpeetz/kontor/tysc/views/VendorForm.java | 115 ++ .../thpeetz/kontor/tysc/views/VendorView.java | 130 ++ .../META-INF/resources/icons/icon.png | Bin 0 -> 45967 bytes .../META-INF/resources/images/offline.png | Bin 0 -> 9507 bytes .../resources/META-INF/resources/offline.html | 38 + springboot/src/main/resources/application.yml | 67 + springboot/src/main/resources/banner.txt | 8 + .../src/main/resources/logback-spring.xml | 42 + .../de/thpeetz/kontor/ApplicationTests.java | 13 + .../kontor/bookshelf/TestConstants.java | 19 + .../data/ArticleAuthorRepositoryTest.java | 74 + .../bookshelf/data/ArticleAuthorTest.java | 77 ++ .../bookshelf/data/ArticleRepositoryTest.java | 61 + .../kontor/bookshelf/data/ArticleTest.java | 45 + .../bookshelf/data/AuthorRepositoryTest.java | 69 + .../kontor/bookshelf/data/AuthorTest.java | 44 + .../data/BookAuthorRepositoryTest.java | 94 ++ .../kontor/bookshelf/data/BookAuthorTest.java | 82 ++ .../bookshelf/data/BookRepositoryTest.java | 87 ++ .../kontor/bookshelf/data/BookTest.java | 56 + .../BookshelfPublisherRepositoryTest.java | 72 + .../data/BookshelfPublisherTest.java | 45 + .../services/BookshelfServiceTest.java | 102 ++ .../kontor/comics/ComicConstantsTest.java | 14 + .../thpeetz/kontor/comics/TestConstants.java | 67 + .../comics/data/ArtistRepositoryTest.java | 73 + .../kontor/comics/data/ArtistTest.java | 48 + .../comics/data/ComicRepositoryTest.java | 53 + .../thpeetz/kontor/comics/data/ComicTest.java | 77 ++ .../comics/data/ComicWorkRepositoryTest.java | 83 ++ .../kontor/comics/data/ComicWorkTest.java | 24 + .../comics/data/IssueRepositoryTest.java | 45 + .../thpeetz/kontor/comics/data/IssueTest.java | 62 + .../comics/data/PublisherRepositoryTest.java | 68 + .../kontor/comics/data/PublisherTest.java | 45 + .../comics/data/StoryArcRepositoryTest.java | 45 + .../kontor/comics/data/StoryArcTest.java | 61 + .../data/TradePaperbackRepositoryTest.java | 59 + .../comics/data/TradePaperbackTest.java | 59 + .../comics/data/VolumeRepositoryTest.java | 61 + .../kontor/comics/data/VolumeTest.java | 64 + .../comics/data/WorktypeRepositoryTest.java | 46 + .../kontor/comics/data/WorktypeTest.java | 83 ++ .../comics/services/ComicServiceTest.java | 350 +++++ .../thpeetz/kontor/media/TestConstants.java | 5 + .../kontor/media/data/MediaArticleTest.java | 32 + .../kontor/media/data/MediaFileTest.java | 37 + .../kontor/media/data/MediaVideoTest.java | 38 + .../services/MediaArticleServiceTest.java | 50 + .../media/services/MediaFileServiceTest.java | 48 + .../media/services/MediaVideoServiceTest.java | 48 + .../de/thpeetz/kontor/tysc/TestConstants.java | 39 + .../kontor/tysc/data/CardRepositoryTest.java | 35 + .../tysc/data/CardSetRepositoryTest.java | 42 + .../thpeetz/kontor/tysc/data/CardSetTest.java | 22 + .../de/thpeetz/kontor/tysc/data/CardTest.java | 22 + .../data/FieldPositionRepositoryTest.java | 59 + .../kontor/tysc/data/FieldPositionTest.java | 44 + .../tysc/data/PlayerRepositoryTest.java | 31 + .../thpeetz/kontor/tysc/data/PlayerTest.java | 53 + .../tysc/data/RoosterRepositoryTest.java | 29 + .../thpeetz/kontor/tysc/data/RoosterTest.java | 47 + .../kontor/tysc/data/SportRepositoryTest.java | 40 + .../thpeetz/kontor/tysc/data/SportTest.java | 44 + .../kontor/tysc/data/TeamRepositoryTest.java | 37 + .../de/thpeetz/kontor/tysc/data/TeamTest.java | 53 + .../tysc/data/VendorRepositoryTest.java | 41 + .../thpeetz/kontor/tysc/data/VendorTest.java | 44 + .../kontor/tysc/services/CardServiceTest.java | 126 ++ .../tysc/services/SportServiceTest.java | 200 +++ .../src/test/resources/application.properties | 30 + 269 files changed, 19844 insertions(+), 4 deletions(-) create mode 100644 springboot/.gitattributes create mode 100644 springboot/.gitignore create mode 100644 springboot/README.md create mode 100644 springboot/build.gradle create mode 100644 springboot/frontend/themes/kontor/styles.css create mode 100644 springboot/frontend/themes/kontor/theme.json create mode 100644 springboot/gradle.properties create mode 100644 springboot/gradle/libs.versions.toml create mode 100644 springboot/gradle/wrapper/gradle-wrapper.jar create mode 100644 springboot/gradle/wrapper/gradle-wrapper.properties create mode 100755 springboot/gradlew create mode 100644 springboot/gradlew.bat create mode 100644 springboot/settings.gradle create mode 100644 springboot/src/docs/asciidoc/kontor-spring.adoc create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistformTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicWorkViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/IssueViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/PublisherViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/StoryArcViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/TradePaperbackViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/VolumeViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/WorktypeViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardSetViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/FieldPositionViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/PlayerViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/RoosterViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/SportViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/TeamViewTest.java create mode 100644 springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/VendorViewTest.java create mode 100644 springboot/src/integrationTest/resources/application.properties create mode 100644 springboot/src/main/java/de/thpeetz/kontor/Application.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/AdminConstants.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/MailProperties.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrix.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrixRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/MailAccountRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumn.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumnRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTable.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTableRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleData.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleDataRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/Role.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/RoleRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/User.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/UserRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/services/AdminService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/services/KontorUserDetailsService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/services/MailService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/services/ModuleService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/AdminLayout.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/LoginView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/UserForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/UserProfileView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/views/UserView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/BookshelfConstants.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/SetupModuleBookshelf.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Article.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthor.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Author.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/AuthorRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Book.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthor.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisher.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/services/BookshelfService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfLayout.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfPublisherView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/PublisherForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/ComicConstants.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/SetupModuleComics.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/Artist.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/ArtistRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/Comic.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWork.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWorkRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/Issue.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/IssueRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/Publisher.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/PublisherRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArc.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArcRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperback.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperbackRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/Volume.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/VolumeRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/Worktype.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/data/WorktypeRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/services/ComicService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicLayout.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperBackForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperbackView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractEntity.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractLinkEntity.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/common/views/AvatarMenuBar.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/common/views/KontorLayoutUtil.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/common/views/MainLayout.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/common/views/MainView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/common/views/SeparateMainLayout.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/mailclient/data/Mail.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/mailclient/data/MailAccount.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/mailclient/views/EmailView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/MediaConstants.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/SetupModuleMedia.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticle.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticleRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFile.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFileRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/data/MediaLink.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideo.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideoRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/services/MediaArticleService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/services/MediaVideoService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/security/SecurityConfig.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/security/SecurityService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/SetupModuleTysc.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/TyscConstants.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/Card.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSet.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSetRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPosition.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPositionRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/Player.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/PlayerRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/Rooster.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/RoosterRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/Sport.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/SportRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/TeamRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/Vendor.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/data/VendorRepository.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/services/CardService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/services/SportService.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamView.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/TyscLayout.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorForm.java create mode 100644 springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorView.java create mode 100644 springboot/src/main/resources/META-INF/resources/icons/icon.png create mode 100644 springboot/src/main/resources/META-INF/resources/images/offline.png create mode 100644 springboot/src/main/resources/META-INF/resources/offline.html create mode 100644 springboot/src/main/resources/application.yml create mode 100644 springboot/src/main/resources/banner.txt create mode 100644 springboot/src/main/resources/logback-spring.xml create mode 100644 springboot/src/test/java/de/thpeetz/kontor/ApplicationTests.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/TestConstants.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/bookshelf/services/BookshelfServiceTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/ComicConstantsTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/TestConstants.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/comics/services/ComicServiceTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/media/TestConstants.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/media/data/MediaArticleTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/media/data/MediaFileTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/media/data/MediaVideoTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/media/services/MediaArticleServiceTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/media/services/MediaFileServiceTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/media/services/MediaVideoServiceTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/TestConstants.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorRepositoryTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/services/CardServiceTest.java create mode 100644 springboot/src/test/java/de/thpeetz/kontor/tysc/services/SportServiceTest.java create mode 100644 springboot/src/test/resources/application.properties diff --git a/.gitignore b/.gitignore index 45f246d..575904a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -__pycache__ .idea/ -bonus -icons -icons-shadowless +__pycache__/ +bonus/ +icons/ +icons-shadowless/ diff --git a/springboot/.gitattributes b/springboot/.gitattributes new file mode 100644 index 0000000..097f9f9 --- /dev/null +++ b/springboot/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/springboot/.gitignore b/springboot/.gitignore new file mode 100644 index 0000000..0294d4c --- /dev/null +++ b/springboot/.gitignore @@ -0,0 +1,32 @@ +.gradle/ +.settings/ +build/ +bin/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +.project +.classpath +.vscode/ +.idea/ +*.lock +logs/ +frontend/generated +frontend/index.html +package*.json +tsconfig.json +types.d.ts +node_modules/ +vite.* +kontor*Db +tags* +kontorHSQLDB* +.vs/ +.winget +src/main/resources/application-local.properties +src/main/resources/application-prod.properties +src/main/resources/application-*.yml diff --git a/springboot/README.md b/springboot/README.md new file mode 100644 index 0000000..f24bbcd --- /dev/null +++ b/springboot/README.md @@ -0,0 +1,3 @@ +# kontor-spring + +Kontor Anwendung mit Spring Boot und Vaadin \ No newline at end of file diff --git a/springboot/build.gradle b/springboot/build.gradle new file mode 100644 index 0000000..8892faf --- /dev/null +++ b/springboot/build.gradle @@ -0,0 +1,240 @@ +buildscript { + configurations.classpath { + resolutionStrategy.eachDependency { DependencyResolveDetails details -> + if (details.requested.group == 'com.burgstaller' && details.requested.name == 'okhttp-digest' && details.requested.version == '1.10') { + details.useTarget "io.github.rburgst:${details.requested.name}:1.21" + details.because 'Dependency has moved' + } + } + } + repositories { + mavenCentral() + maven { setUrl("https://maven.vaadin.com/vaadin-prereleases") } + maven { setUrl("https://repo.spring.io/milestone") } + } +} + +plugins { + id 'java' + id 'application' + id 'maven-publish' + id "com.google.cloud.artifactregistry.gradle-plugin" version "2.2.0" + id 'jvm-test-suite' + id 'jacoco' + id 'test-report-aggregation' + id 'jacoco-report-aggregation' + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependencies) + alias(libs.plugins.vaadin) + alias(libs.plugins.lombok) + alias(libs.plugins.asciidoctorPdf) + alias(libs.plugins.asciidoctorConvert) + alias(libs.plugins.asciidoctorGems) +} + +repositories { + mavenCentral() + ruby.gems() + maven { setUrl("https://maven.vaadin.com/vaadin-prereleases") } + maven { setUrl("https://repo.spring.io/milestone") } + maven { setUrl("https://maven.vaadin.com/vaadin-addons") } +} + +sourceCompatibility = '17' + +configurations { + developmentOnly + runtimeClasspath { + extendsFrom developmentOnly + } +} + +dependencies { + implementation 'com.vaadin:vaadin-core' + implementation 'com.vaadin:vaadin-spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'org.springframework.security:spring-security-oauth2-resource-server' + implementation 'com.h2database:h2' + implementation libs.hsqldb + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + implementation 'com.sun.mail:javax.mail:1.6.2' + implementation 'org.hibernate.orm:hibernate-community-dialects' + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'com.vaadin:vaadin-testbench-junit5' + testImplementation 'io.projectreactor:reactor-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + asciidoctorGems libs.rouge + //asciidoctorGems libs.diagram +} + +def pdfFile = layout.buildDirectory.file("docs/asciidocPdf/kontor-spring.pdf") +def pdfArtifact = artifacts.add('archives', pdfFile.get().asFile) { + type 'pdf' + builtBy asciidoctorPdf +} + +publishing { + publications { + maven(MavenPublication) { + groupId = group + '.docs' + artifactId = project.name + artifact pdfArtifact + } + bootJava(MavenPublication) { + artifact tasks.named("bootDistTar") + } + } + repositories { + maven { + name = "gitlabPackageRegistry" + url = uri("https://gitlab.com/api/v4/projects/62010300/packages/maven") + credentials(PasswordCredentials) + } + } +} + +final BUILD_DATE = new Date().format('dd.MM.yyyy').toString() + +asciidoctorPdf { + dependsOn asciidoctorGemsPrepare + + baseDirFollowsSourceFile() + + asciidoctorj { + modules { + diagram.use() + } + requires 'rouge' + attributes 'build-gradle': file('build.gradle'), + 'endpoint-url': 'https://www.thpeetz.de', + 'source-highlighter': 'rouge', + 'imagesdir': './images', + 'toc': 'left', + 'toc-title': 'Inhaltsverzeichnis', + 'revdate': BUILD_DATE, + 'revnumber': { project.version.toString() }, + 'revremark': 'Entwurf', + 'chapter-label': '', + 'icons': 'font', + 'idprefix': 'id_', + 'idseparator': '-', + 'docinfo1': '' + } +} + +build.dependsOn asciidoctorPdf + +dependencyManagement { + imports { + mavenBom libs.vaadin.bom.get().toString() + } +} + +application { + mainClass = 'de.thpeetz.kontor.Application' +} + +bootRun { + args = ["--spring.profiles.active=${project.properties['profile'] ?: 'prod'}"] +} + +vaadin { + productionMode = true +} + +testing { + suites { + configureEach { + useJUnitJupiter() + dependencies { + implementation project() + implementation 'com.vaadin:vaadin-core' + implementation 'com.vaadin:vaadin-spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.h2database:h2' + implementation libs.hsqldb + implementation libs.sqlite.jdbc + //runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + implementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + implementation 'org.springframework.security:spring-security-test' + implementation 'com.vaadin:vaadin-testbench-junit5' + implementation 'io.projectreactor:reactor-test' + runtimeOnly 'org.junit.platform:junit-platform-launcher' + } + } + test(JvmTestSuite) { + testType = TestSuiteType.UNIT_TEST + targets { + all { + testTask.configure { + reports { + junitXml { + outputPerTestCase = true // defaults to false + mergeReruns = true // defaults to false + } + } + finalizedBy(jacocoTestReport) + } + } + } + } + integrationTest(JvmTestSuite) { + testType = "view-test" + targets { + all { + testTask.configure { + shouldRunAfter(test) + finalizedBy(jacocoTestReport) + } + } + } + } + } +} + +tasks.named('check') { + dependsOn(testing.suites.integrationTest) + dependsOn(testing.suites.test) + dependsOn tasks.named('testAggregateTestReport', TestReport) + dependsOn tasks.named('integrationTestAggregateTestReport', TestReport) +} + + +jacocoTestReport { + dependsOn test, integrationTest + reports { + xml.required = true + csv.required = false + } +} + +reporting { + reports { + testAggregateTestReport(AggregateTestReport) { + testType = TestSuiteType.UNIT_TEST + } + integrationTestAggregateTestReport(AggregateTestReport) { + testType = "view-test" + } + integrationTestCodeCoverageReport(JacocoCoverageReport) { + testType = "view-test" + } + } +} + +wrapper { + gradleVersion = "8.6" +} diff --git a/springboot/frontend/themes/kontor/styles.css b/springboot/frontend/themes/kontor/styles.css new file mode 100644 index 0000000..843559f --- /dev/null +++ b/springboot/frontend/themes/kontor/styles.css @@ -0,0 +1,10 @@ +@media all and (max-width: 1100px) { + .list-view.editing .toolbar, + .list-view.editing .contact-grid { + display: none; + } +} +a[highlight] { + font-weight: bold; + text-decoration: underline; +} diff --git a/springboot/frontend/themes/kontor/theme.json b/springboot/frontend/themes/kontor/theme.json new file mode 100644 index 0000000..0f7a81f --- /dev/null +++ b/springboot/frontend/themes/kontor/theme.json @@ -0,0 +1,3 @@ +{ + "lumoImports" : [ "typography", "color", "spacing", "badge", "utility" ] +} \ No newline at end of file diff --git a/springboot/gradle.properties b/springboot/gradle.properties new file mode 100644 index 0000000..f305204 --- /dev/null +++ b/springboot/gradle.properties @@ -0,0 +1,3 @@ +description='Kontor with Spring Boot' +version=0.1.0-SNAPSHOT +group=de.thpeetz diff --git a/springboot/gradle/libs.versions.toml b/springboot/gradle/libs.versions.toml new file mode 100644 index 0000000..6ec0bbe --- /dev/null +++ b/springboot/gradle/libs.versions.toml @@ -0,0 +1,58 @@ +[versions] +gradle = "8.6" +args4j = "2.33" +commonscli = "1.5.0" +junit = "5.8.2" +logback = "1.1.2" +mockito = "1.9.5" +picoli = "4.7.0" +slf4j = "1.7.22" +hsqldb = "2.7.1" +sqlite = "3.25.2" +spotbugs = "6.0.7" +asciidoctor = "4.0.2" +rouge = "3.15.0" +#diagram = "2.2.2" +diagram = "2.3.1" +sonarqube = "3.3" +cimtConventions = "1.0.0-SNAPSHOT" +springboot = "3.2.5" +springdependencies = "1.1.4" +vaadin = "24.3.8" +lombok = "8.6" + +[libraries] +args4j = { module = "args4j:args4j", version.ref = "args4j" } +commonscli = { module = "commons-cli:commons-cli", version.ref = "commonscli" } +junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +logbackCore = { module = "ch.qos.logback:logback-core", version.ref = "logback" } +logbackClassic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +mockito = { module = "org.mockito:mockito-all", version.ref = "mockito" } +picocli = { module = "info.picocli:picocli", version.ref = "picoli" } +slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +hsqldb = { module = "org.hsqldb:hsqldb", version.ref = "hsqldb" } +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } +vaadin-bom = { module = "com.vaadin:vaadin-bom", version.ref = "vaadin" } +asciidoctorGradleJvmGems = { module = "org.asciidoctor:asciidoctor-gradle-jvm-gems", version.ref= "asciidoctor" } +asciidoctorGradleJvm = { module = "org.asciidoctor:asciidoctor-gradle-jvm", version.ref= "asciidoctor" } +asciidoctorGradleJvmPdf = { module = "org.asciidoctor:asciidoctor-gradle-jvm-pdf", version.ref= "asciidoctor" } +rouge = { module = "rubygems:rouge", version.ref = "rouge" } +diagram = { module = "rubygems:asciidoctor-diagram", version.ref = "diagram" } + +[bundles] +logback = ["logbackCore", "logbackClassic"] + +[plugins] +spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugs" } +sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" } +asciidoctorPdf = { id = "org.asciidoctor.jvm.pdf", version.ref = "asciidoctor" } +asciidoctorConvert = { id = "org.asciidoctor.jvm.convert", version.ref = "asciidoctor" } +asciidoctorGems = { id = "org.asciidoctor.jvm.gems", version.ref = "asciidoctor" } +javaConvention = { id = "de.cimt.java-conventions", version.ref = "cimtConventions" } +applicationConvention = { id = "de.cimt.application-conventions", version.ref = "cimtConventions" } +libraryConvention = { id = "de.cimt.library-conventions", version.ref = "cimtConventions" } +asciidoctorConvention = { id = "de.cimt.asciidoctor-conventions", version.ref = "cimtConventions" } +spring-boot = { id = "org.springframework.boot", version.ref = "springboot"} +spring-dependencies = { id = "io.spring.dependency-management", version.ref = "springdependencies" } +vaadin = { id = "com.vaadin", version.ref = "vaadin" } +lombok = { id = "io.freefair.lombok", version.ref = "lombok" } diff --git a/springboot/gradle/wrapper/gradle-wrapper.jar b/springboot/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/springboot/gradle/wrapper/gradle-wrapper.properties b/springboot/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a80b22c --- /dev/null +++ b/springboot/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/springboot/gradlew b/springboot/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/springboot/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/springboot/gradlew.bat b/springboot/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/springboot/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/springboot/settings.gradle b/springboot/settings.gradle new file mode 100644 index 0000000..f559dac --- /dev/null +++ b/springboot/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + resolutionStrategy { + eachPlugin { + if (requested.id.id == 'org.springframework.boot') { + useModule("org.springframework.boot:spring-boot-gradle-plugin:${requested.version}") + } + if (requested.id.id == 'org.gradle.toolchains.foojay-resolver') { + useModule("org.gradle.toolchains.foojay-resolver-convention:0.4.0") + } + } + } + repositories { + gradlePluginPortal() + mavenCentral() + maven { setUrl("https://maven.vaadin.com/vaadin-prereleases") } + maven { setUrl("https://repo.spring.io/milestone") } + maven { url 'https://plugins.gradle.org/m2/' } + } +// plugins { +// id 'com.vaadin' version "${vaadinVersion}" +// } +} + +rootProject.name = 'kontor-spring' diff --git a/springboot/src/docs/asciidoc/kontor-spring.adoc b/springboot/src/docs/asciidoc/kontor-spring.adoc new file mode 100644 index 0000000..bd4ee00 --- /dev/null +++ b/springboot/src/docs/asciidoc/kontor-spring.adoc @@ -0,0 +1,509 @@ += Projektbeschreibung kontor-spring: Entwicklungs- und Projekthandbuch +:author: Thomas Peetz +:email: +:doctype: book +:sectnums: +:sectnumlevels: 4 +:toc: +:toclevels: 4 +:table-caption!: +:counter: table-number: 0 + +[title="Dokumenthistorie", id="Table-{counter:table-number}", options="header"] +|=== +| Version | Datum | Autor | Änderungsgrund / Bemerkungen +| 1.0.0 | 16.05.2022 | Thomas Peetz | Ersterstellung +|=== + +== Allgemeines + +=== Zweck des Dokumentes + +Das Entwicklungshandbuch beschreibt die Werkzeuge und die Vorgehensweise bei der Entwicklung +im Projekt kontor-spring und der Erstellung der Dokumentation. + +=== Verwendete Tools + +==== Gitea + +Für die Verwaltung des Sourcecode kommt ((Gitea))<> zum Einsatz. +Mit Gitea werden auch die Projektaufgaben verwaltet. + +Das Projekt und das dazugehörige Git Repository sind unter der Adresse + +https://gitea.thpeetz.de/kontor/kontor-spring + +zu finden. + +== Erstellung der Dokumentation + +Die Dokumentation des Projektes wird mit ((Asciidoctor))<> geschrieben. +Die Dokumente erhalten ihre Namen nach dem jeweiligen Hauptdokument. + +=== Quellcode Verwaltung + +Die Asciidoctor-Dateien haben die Endung `.adoc`. + +=== Buildsystem + +Zur Erstellung der PDF-Dateien aus den Asciidoctor-Dateien wird das Buildsystem ((Gradle))<> verwendet. +Die Dateien für die Dokumente liegen im Verzeichnis `src/docs/asciidoc`. + +Der Gradle Build wird über die Datei `build.gradle` definiert. + + +== Einführung + +=== Zweck + +=== Stakeholder des Systems + +=== Systemumfang + +==== Zielsetzung des Systems + +=== Systemübersicht + +==== Systemkontext + +==== Systemarchitektur + +==== Systemschnittstellen + +===== Realisierte Schnittstellen + +===== Verwendete Schnittstellen + +==== Logisches Datenmodell + +===== Benutzer ER-Diagramm + +[mermaid, kontor-user-er, png] +.Benutzer ER-Diagramm +.... +erDiagram + user { + string id PK + datetime created_date + datetime last_modified_date + int version + string email + boolean enabled + string firstName + string lastName + string password + string token + boolean tokenExpired + string userName UNIQUE + } + role { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + } + authorization_matrix { + string id PK + datetime created_date + datetime last_modified_date + int version + string user_id FK + string role_id FK + } + module_data { + string id PK + datetime created_date + datetime last_modified_date + int version + boolean import_data + string module_name UNIQUE + } + user ||--o{ authorization_matrix : "matrix" + role ||--o{ authorization_matrix : "matrix" +.... + + +===== Comics ER-Diagramm + +[mermaid, kontor-comics-er, png] +.Comics ER-Diagramm +.... +erDiagram + comic { + string id PK + datetime created_date + datetime last_modified_date + int version + boolean completed + boolean currentOrder + string title + string publisher_id FK + } + volume { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + string comic_id FK + } + issue { + string id PK + datetime created_date + datetime last_modified_date + int version + boolean in_stock + boolean is_read + string issue_number + string comic_id FK + string volume_id FK + } + publisher { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + } + artist { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + } + story_arc { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + string comic_id FK + } + trade_paperback { + string id PK + datetime created_date + datetime last_modified_date + int version + int issueStart + int issueEnd + string name + string comic_id FK + } + worktype { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + } + comic_work { + string id PK + datetime created_date + datetime last_modified_date + int version + string artist_id FK + string comic_id FK + string worktype_id FK + } + comic ||--o{ comic_work : "1" + artist ||--o{ comic_work : "1" + worktype ||--o{ comic-work : "1" + publisher ||--o{ comic : "1" + comic ||--o{ issue : "1" + comic ||--o{ volume : "1" + comic ||--o{ story_arc : "1" + comic ||--o{ trade_paperback : "1" + volume ||--o{ issue : "1" +.... + +===== TYSC ER-Diagramm + +[mermaid, kontor-tysc-er, png] +.TYSC ER-Diagramm +.... +erDiagram + sport { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + } + team { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + string short_name + string sport_id FK + } + field_position { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + string short_name + string sport_id FK + } + rooster { + string id PK + datetime created_date + datetime last_modified_date + int version + int year + string player_id FK + string position_id FK + string team_id FK + } + player { + string id PK + datetime created_date + datetime last_modified_date + int version + string first_name + string last_name + } + vendor { + string id PK + datetime created_date + datetime last_modified_date + int version + string name + } + card_set { + string id PK + datetime created_date + datetime last_modified_date + int version + boolean insert_set + string name + boolean parallel_set + string vendor_id FK + } + card { + string id PK + datetime created_date + datetime last_modified_date + int version + int cardNumber + int year + string card_set FK + string rooster_id FK + string vendor_id FK + } + sport ||--o{ team : "1" + sport ||--o{ field_position : "1" + field_position ||--o{ rooster : "1" + player ||--o{ rooster : "1" + team ||--o{ rooster : "1" + vendor ||--o{ card : "1" + card_set ||--o{ card : "1" + rooster ||--o{ card : "1" +.... + +===== Bookshelf ER-Diagramm + +[mermaid, kontor-bookshelf-er, png] +.Bookshelf ER-Diagramm +.... +erDiagram + article { + string id PK + datetime created_date + datetime last_modified_date + int version + string title + } + book { + string id PK + datetime created_date + datetime last_modified_date + int version + string isbn UNIQUE + string title + int year + string publisher_id FK + } + bookshelf_publisher { + string id PK + datetime created_date + datetime last_modified_date + int version + string name UNIQUE + } + author { + string id PK + datetime created_date + datetime last_modified_date + int version + string first_name + string last_name + } + article_author { + string id PK + datetime created_date + datetime last_modified_date + int version + string article_id FK + string author_id FK + } + book_author { + string id PK + datetime created_date + datetime last_modified_date + int version + string book_id FK + string author_id FK + } + publisher ||--o{ book : "1" + article ||--o{ article_author : "1" + author ||--o{ article_author : "1" + book ||--o{ book_author : "1" + author ||--o{ book_author : "1" +.... + +===== Mail ER-Diagramm + +[mermaid, kontor-mail-er, png] +.Mail ER-Diagramm +.... +erDiagram + mail { + string id PK + datetime created_date + datetime last_modified_date + int version + string subject + string content + datetime received_date + datetime sent_date + } + mail_account { + string id PK + datetime created_date + datetime last_modified_date + int version + string host + string password + int port + string protocol + boolean start_tls + string user_name + } + mail_address { + string id PK + datetime created_date + datetime last_modified_date + int version + string internet_address UNIQUE + string personal + string user_id FK + } + user ||--o{ mail_address : "1" +.... + +==== Einschränkungen + +== Anforderungen der Domäne + +=== Systemfunktionen + +==== Anwendungsfälle + +==== Akteure + +==== Zielgruppen + +=== Anforderungen + +==== Anforderungen an externe Schnittstellen + +==== Funktionale Anforderungen + +==== Qualitätsanforderungen + +==== Randbedingungen + +==== Weitere Anforderungen + +==== Wartungs- und Supportinformationen + +=== Verifikation + +== Projektbeschreibung + +=== Ausgangslage + +//==== Rechtliche Vorgaben und Rahmenbedingungen +//=== Rahmenbedingungen + +//==== Vorhandene Regelungen + +=== Projektziele + +=== Projektabgrenzung + +//=== Voraussichtliche Kosten + +//=== Projektrisiken + +//==== Produktivität + +//==== Finanzielle Risiken + +//==== Akzeptanz + +== Projektorganisation + +=== Projekt-Aufbauorganisation + +=== Rollendefinition + +//==== Projektauftraggeber + +//==== Projektausschuss + +//==== Beratung / Qualitätssicherung + +==== Projekteiter + +==== Projektteam + +==== Liste der Stakeholder + +=== Projektablauforganisation + +==== Projekt-Phasen + +===== Erstellung der Projektdokumentation + + +== Verschiedenes + +=== Erreichbarkeiten + +[bibliography] +== Referenzen + +- [[[asciidoctor]]] http://asciidoctor.org +- [[[gitea]]] http://www.gitea.org +- [[[gradle]]] http://www.gradle.org +- [[[jenkins]]] http://jenkins-ci.org + +[glossary] +== Glossar + +[index] +== Index + +== Verzeichnisse + +=== Abbildungsverzeichnis + +=== Tabellenverzeichnis + +<> <> diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistViewTest.java new file mode 100644 index 0000000..e2eec07 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistViewTest.java @@ -0,0 +1,41 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.Artist; + +@SpringBootTest +class ArtistViewTest { + + @Autowired + private ArtistView artistView; + + @Test + void formShownWhenArtistSelected() { + Grid grid = artistView.getGrid(); + Artist firstArtist = getFirstItem(grid); + + ArtistForm form = artistView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstArtist); + assertTrue(form.isVisible()); + assertEquals(firstArtist.getName(), form.name.getValue()); + } + + private Artist getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List artists = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(5, count); + return artists.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistformTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistformTest.java new file mode 100644 index 0000000..a6aea11 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ArtistformTest.java @@ -0,0 +1,63 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.data.Artist; + +@SpringBootTest +class ArtistformTest { + + private Artist artist1; + private static final String ARTISTNAME= "Lee, Stan"; + + @BeforeEach + void setupData() { + artist1 = new Artist(); + artist1.setName(ARTISTNAME); + } + + @Test + void formFieldsPopulated() { + ArtistForm form = new ArtistForm(); + form.setArtist(artist1); + assertEquals(ARTISTNAME, form.name.getValue()); + } + + @Test + void saveEventHasCorrectValues() { + ArtistForm form = new ArtistForm(); + Artist artist = new Artist(); + form.setArtist(artist); + form.name.setValue(ARTISTNAME); + + AtomicReference savedArtistReference = new AtomicReference<>(null); + form.addSaveListener(e -> { + savedArtistReference.set(e.getArtist()); + }); + form.save.click(); + Artist savedArtist = savedArtistReference.get(); + assertEquals(ARTISTNAME, savedArtist.getName()); + } + + @Test + void deleteEventHasCorrectValues() { + ArtistForm form = new ArtistForm(); + Artist artist = new Artist(); + form.setArtist(artist); + form.name.setValue(ARTISTNAME); + + AtomicReference deletedArtistReference = new AtomicReference<>(null); + form.addDeleteListener(e -> { + deletedArtistReference.set(e.getArtist()); + }); + form.delete.click(); + Artist deletedArtist = deletedArtistReference.get(); + assertEquals(ARTISTNAME, deletedArtist.getName()); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicViewTest.java new file mode 100644 index 0000000..084d64e --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.Comic; + +@SpringBootTest +public class ComicViewTest { + + @Autowired + private ComicView comicView; + + @Test + void formShownWhenComicSelected() { + Grid grid = comicView.getGrid(); + Comic firstComic = getFirstItem(grid); + + ComicForm form = comicView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstComic); + assertTrue(form.isVisible()); + assertEquals(firstComic.getTitle(), form.title.getValue()); + } + + private Comic getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List comics = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(169, count); + return comics.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicWorkViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicWorkViewTest.java new file mode 100644 index 0000000..0d3113e --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/ComicWorkViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.ComicWork; + +@SpringBootTest +class ComicWorkViewTest { + + @Autowired + private ComicWorkView comicWorkView; + + @Test + void formShownWhenComicSelected() { + Grid grid = comicWorkView.getGrid(); + ComicWork firstComicWork = getFirstItem(grid); + + ComicWorkForm form = comicWorkView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstComicWork); + assertTrue(form.isVisible()); + assertEquals(firstComicWork.getComic(), form.comic.getValue()); + } + + private ComicWork getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List comicWorks = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(18, count); + return comicWorks.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/IssueViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/IssueViewTest.java new file mode 100644 index 0000000..089f8f5 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/IssueViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.Issue; + +@SpringBootTest +public class IssueViewTest { + + @Autowired + private IssueView issueView; + + @Test + void formShownWhenIssueSelected() { + Grid grid = issueView.getGrid(); + Issue firstIssue = getFirstItem(grid); + + IssueForm form = issueView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstIssue); + assertTrue(form.isVisible()); + assertEquals(firstIssue.getIssueNumber(), form.issueNumber.getValue()); + } + + private Issue getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List issues = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(750, count); + return issues.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/PublisherViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/PublisherViewTest.java new file mode 100644 index 0000000..b3bab80 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/PublisherViewTest.java @@ -0,0 +1,41 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.Publisher; + +@SpringBootTest +class PublisherViewTest { + + @Autowired + private PublisherView publisherView; + + @Test + void formShownWhenPublisherSelected() { + Grid grid = publisherView.getGrid(); + Publisher firstPublisher = getFirstItem(grid); + + PublisherForm form = publisherView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstPublisher); + assertTrue(form.isVisible()); + assertEquals(firstPublisher.getName(), form.name.getValue()); + } + + private Publisher getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List publishers = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(18, count); + return publishers.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/StoryArcViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/StoryArcViewTest.java new file mode 100644 index 0000000..56f9c34 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/StoryArcViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.StoryArc; + +@SpringBootTest +class StoryArcViewTest { + + @Autowired + private StoryArcView storyArcView; + + @Test + void formShownWhenStoryArcSelected() { + Grid grid = storyArcView.getGrid(); + StoryArc firstStoryArc = getFirstItem(grid); + + StoryArcForm form = storyArcView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstStoryArc); + assertTrue(form.isVisible()); + assertEquals(firstStoryArc.getName(), form.name.getValue()); + } + + private StoryArc getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List storyArcs = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(3, count); + return storyArcs.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/TradePaperbackViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/TradePaperbackViewTest.java new file mode 100644 index 0000000..a3b4119 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/TradePaperbackViewTest.java @@ -0,0 +1,47 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.TradePaperback; +import de.thpeetz.kontor.comics.data.Volume; + +@SpringBootTest +class TradePaperbackViewTest { + + @Autowired + private TradePaperbackView tradePaperbackView; + + @Test + void formShownWhenVolumeSelected() { + Grid grid = tradePaperbackView.getGrid(); + + TradePaperback firstTradePaperback = getFirstItem(grid); + + TradePaperBackForm form = tradePaperbackView.getForm(); + assertFalse(form.isVisible()); + + if (firstTradePaperback != null) { + grid.asSingleSelect().setValue(firstTradePaperback); + assertTrue(form.isVisible()); + assertEquals(firstTradePaperback.getName(), form.name.getValue()); + } + } + + private TradePaperback getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List tradePaperbacks = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(40, count); + return tradePaperbacks.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/VolumeViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/VolumeViewTest.java new file mode 100644 index 0000000..15e8d15 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/VolumeViewTest.java @@ -0,0 +1,50 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.Volume; + +@SpringBootTest +class VolumeViewTest { + + @Autowired + private VolumeView volumeView; + + @Test + void formShownWhenVolumeSelected() { + Grid grid = volumeView.getGrid(); + + Volume firstVolume = getFirstItem(grid); + + VolumeForm form = volumeView.getForm(); + assertFalse(form.isVisible()); + + if (firstVolume != null) { + grid.asSingleSelect().setValue(firstVolume); + assertTrue(form.isVisible()); + assertEquals(firstVolume.getName(), form.name.getValue()); + } + } + + private Volume getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List volumes = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(0, count); + if (count > 0) { + return volumes.get(0); + } else { + return null; + } + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/WorktypeViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/WorktypeViewTest.java new file mode 100644 index 0000000..2f15672 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/comics/views/WorktypeViewTest.java @@ -0,0 +1,49 @@ +package de.thpeetz.kontor.comics.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.comics.data.Worktype; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +class WorktypeViewTest { + + @Autowired + private WorktypeView worktypeView; + + @Test + void formShownWhenWorktypeSelected() { + Grid grid = worktypeView.getGrid(); + + Worktype firstWorktype = getFirstItem(grid); + + WorktypeForm form = worktypeView.getForm(); + assertFalse(form.isVisible()); + + if (firstWorktype != null) { + grid.asSingleSelect().setValue(firstWorktype); + assertTrue(form.isVisible()); + assertEquals(firstWorktype.getName(), form.name.getValue()); + } + } + + private Worktype getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List worktypes = grid.getListDataView().getItems().collect(Collectors.toList()); + log.info("found worktypes: {}", worktypes); + assertEquals(3, count); + return worktypes.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardSetViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardSetViewTest.java new file mode 100644 index 0000000..40d3fbb --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardSetViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.CardSet; + +@SpringBootTest +class CardSetViewTest { + + @Autowired + private CardSetView cardSetView; + + @Test + void formShownWhenCardSetSelected() { + Grid grid = cardSetView.getGrid(); + CardSet firstCardSet = getFirstItem(grid); + + CardSetForm form = cardSetView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstCardSet); + assertTrue(form.isVisible()); + assertEquals(firstCardSet.getName(), form.name.getValue()); + } + + private CardSet getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List cardSets = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(15, count); + return cardSets.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardViewTest.java new file mode 100644 index 0000000..0a5a3ed --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/CardViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.Card; + +@SpringBootTest +class CardViewTest { + + @Autowired + private CardView cardView; + + @Test + void formShownWhenCardSelected() { + Grid grid = cardView.getGrid(); + Card firstCard = getFirstItem(grid); + + CardForm form = cardView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstCard); + assertTrue(form.isVisible()); + assertEquals(String.valueOf(firstCard.getCardNumber()), form.cardNumber.getValue()); + } + + private Card getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List cards = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(10, count); + return cards.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/FieldPositionViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/FieldPositionViewTest.java new file mode 100644 index 0000000..737b74d --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/FieldPositionViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.FieldPosition; + +@SpringBootTest +class FieldPositionViewTest { + + @Autowired + private PositionView positionView; + + @Test + void formShownWhenPositionSelected() { + Grid grid = positionView.getGrid(); + FieldPosition firstFieldPosition = getFirstItem(grid); + + PositionForm form = positionView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstFieldPosition); + assertTrue(form.isVisible()); + assertEquals(firstFieldPosition.getName(), form.name.getValue()); + } + + private FieldPosition getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List positions = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(44, count); + return positions.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/PlayerViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/PlayerViewTest.java new file mode 100644 index 0000000..5e7d640 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/PlayerViewTest.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.Player; + +@SpringBootTest +class PlayerViewTest { + + @Autowired + private PlayerView playerView; + + @Test + void formShownWhenPlayerSelected() { + Grid grid = playerView.getGrid(); + Player firstPlayer = getFirstItem(grid); + + PlayerForm form = playerView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstPlayer); + assertTrue(form.isVisible()); + assertEquals(firstPlayer.getLastName(), form.lastName.getValue()); + assertEquals(firstPlayer.getFirstName(), form.firstName.getValue()); + } + + private Player getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List players = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(38, count); + return players.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/RoosterViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/RoosterViewTest.java new file mode 100644 index 0000000..d04f306 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/RoosterViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.Rooster; + +@SpringBootTest +class RoosterViewTest { + + @Autowired + private RoosterView roosterView; + + @Test + void formShownWhenRoosterSelected() { + Grid grid = roosterView.getGrid(); + Rooster firstRooster = getFirstItem(grid); + + RoosterForm form = roosterView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstRooster); + assertTrue(form.isVisible()); + assertEquals(firstRooster.getYear(), form.year.getValue()); + } + + private Rooster getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List roosters = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(11, count); + return roosters.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/SportViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/SportViewTest.java new file mode 100644 index 0000000..ca86d0a --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/SportViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.Sport; + +@SpringBootTest +class SportViewTest { + + @Autowired + private SportView sportView; + + @Test + void formShownWhenSportSelected() { + Grid grid = sportView.getGrid(); + Sport firstSport = getFirstItem(grid); + + SportForm form = sportView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstSport); + assertTrue(form.isVisible()); + assertEquals(firstSport.getName(), form.name.getValue()); + } + + private Sport getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List sports = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(4, count); + return sports.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/TeamViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/TeamViewTest.java new file mode 100644 index 0000000..da0ea19 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/TeamViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.Team; + +@SpringBootTest +class TeamViewTest { + + @Autowired + private TeamView teamView; + + @Test + void formShownWhenTeamSelected() { + Grid grid = teamView.getGrid(); + Team firstTeam = getFirstItem(grid); + + TeamForm form = teamView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstTeam); + assertTrue(form.isVisible()); + assertEquals(firstTeam.getName(), form.name.getValue()); + } + + private Team getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List teams = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(122, count); + return teams.get(0); + } +} diff --git a/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/VendorViewTest.java b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/VendorViewTest.java new file mode 100644 index 0000000..ef80d22 --- /dev/null +++ b/springboot/src/integrationTest/java/de/thpeetz/kontor/tysc/views/VendorViewTest.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.tysc.views; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.vaadin.flow.component.grid.Grid; + +import de.thpeetz.kontor.tysc.data.Vendor; + +@SpringBootTest +class VendorViewTest { + + @Autowired + private VendorView vendorView; + + @Test + void formShownWhenVendorSelected() { + Grid grid = vendorView.getGrid(); + Vendor firstVendor = getFirstItem(grid); + + VendorForm form = vendorView.getForm(); + + assertFalse(form.isVisible()); + grid.asSingleSelect().setValue(firstVendor); + assertTrue(form.isVisible()); + assertEquals(firstVendor.getName(), form.name.getValue()); + } + + private Vendor getFirstItem(Grid grid) { + int count = grid.getListDataView().getItemCount(); + List vendors = grid.getListDataView().getItems().collect(Collectors.toList()); + assertEquals(9, count); + return vendors.get(0); + } +} diff --git a/springboot/src/integrationTest/resources/application.properties b/springboot/src/integrationTest/resources/application.properties new file mode 100644 index 0000000..27a1935 --- /dev/null +++ b/springboot/src/integrationTest/resources/application.properties @@ -0,0 +1,30 @@ +server.port=8085 + +spring.hibernate.dialect=org.hibernate.dialect.HSQLDialect +spring.jpa.database-platform=org.hibernate.dialect.HSQLDialect +spring.datasource.driverClassName=org.hsqldb.jdbc.JDBCDriver +spring.datasource.url=jdbc:hsqldb:mem:itDb +spring.datasource.username=sa +spring.datasource.password=sa + +#spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect +#spring.datasource.driverClassName=org.sqlite.JDBC +#spring.datasource.url=jdbc:sqlite:file:./kontorITDb?cache=shared +#spring.datasource.username=sa +#spring.datasource.password=sa + +spring.jpa.defer-datasource-initialization = true +#spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.sql.init.mode=always + +spring.mustache.check-template-location = false + +logging.level.org.atmosphere=INFO +logging.level.org.springframework.web=INFO +logging.level.guru.springframework.controllers=DEBUG +logging.level.org.hibernate=INFO +logging.level.de.thpeetz=DEBUG + +jwt.auth.secret=J6GOtcwC2NJI1l0VkHu20PacPFGTxpirBxWwynoHjsc= diff --git a/springboot/src/main/java/de/thpeetz/kontor/Application.java b/springboot/src/main/java/de/thpeetz/kontor/Application.java new file mode 100644 index 0000000..c304f35 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/Application.java @@ -0,0 +1,24 @@ +package de.thpeetz.kontor; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.server.PWA; +import com.vaadin.flow.theme.Theme; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Slf4j +@EnableJpaAuditing +@SpringBootApplication +@Theme(value = "kontor") +@PWA(name = "Vaadin Kontor", shortName = "Kontor", offlinePath = "offline.html", offlineResources = { "images/offline.png" }) +public class Application implements AppShellConfigurator { + + public static void main(String[] args) { + log.info("Starting Kontor application"); + SpringApplication.run(Application.class); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/AdminConstants.java b/springboot/src/main/java/de/thpeetz/kontor/admin/AdminConstants.java new file mode 100644 index 0000000..63f11b1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/AdminConstants.java @@ -0,0 +1,53 @@ +package de.thpeetz.kontor.admin; + +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.sidenav.SideNavItem; +import com.vaadin.flow.router.RouterLink; +import de.thpeetz.kontor.admin.views.*; +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.views.ComicWorkView; + +public class AdminConstants { + + + private AdminConstants() { + // private constructor to hide the implicit public one + } + + public static final String ADMIN_TITLE = "Verwaltung"; + public static final String AUTHORIZATION = "Berechtigungen"; + public static final String AUTHORIZATION_ROUTE = "/admin/authorization"; + public static final String DATA = "Daten"; + public static final String METADATA_ROUTE = "admin/metadata"; + public static final String ROLE = "Rollen"; + public static final String ROLE_ROUTE = "/admin/role"; + public static final String USER = "Benutzer"; + public static final String USER_ROUTE = "/admin/user"; + public static final String ADMIN = "admin"; + public static final String ADMIN_ROUTE = "/admin"; + + public static RouterLink getUserNavigation() { + return new RouterLink(USER, UserView.class); + } + + public static RouterLink getRoleNavigation() { + return new RouterLink(ROLE, RoleView.class); + } + + public static RouterLink getAuthorizationNavigation() { + return new RouterLink(AUTHORIZATION, AuthorizationView.class); + } + + public static SideNavItem getAdminNavigation() { + SideNavItem administration = new SideNavItem(ADMIN_TITLE, USER_ROUTE, VaadinIcon.GROUP.create()); + administration.addItem(new SideNavItem(USER, USER_ROUTE, VaadinIcon.USERS.create())); + administration.addItem(new SideNavItem(ROLE, RoleView.class)); + SideNavItem data = new SideNavItem(DATA, AUTHORIZATION_ROUTE, VaadinIcon.DATABASE.create()); + data.addItem(new SideNavItem(ComicConstants.COMICWORK, ComicWorkView.class)); + data.addItem(new SideNavItem(AUTHORIZATION, AuthorizationView.class)); + data.addItem(new SideNavItem("Data Import", ModuleDataView.class)); + data.addItem(new SideNavItem("Meta Data", MetaDataView.class)); + administration.addItem(data); + return administration; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/MailProperties.java b/springboot/src/main/java/de/thpeetz/kontor/admin/MailProperties.java new file mode 100644 index 0000000..9e8a6b4 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/MailProperties.java @@ -0,0 +1,78 @@ +package de.thpeetz.kontor.admin; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "mail") +public class MailProperties { + + private String protocol; + private String host; + private Integer port; + private String userName; + private String password; + private Boolean startTls; + + public String getProtocol() { + return protocol; + } + + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public Integer getPort() { + return port; + } + + public void setPort(Integer port) { + this.port = port; + } + + public Boolean getStartTls() { + return startTls; + } + + public void setStartTls(Boolean startTls) { + this.startTls = startTls; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("MailProperties{"); + sb.append("protocol='").append(protocol).append('\''); + sb.append(", host='").append(host).append('\''); + sb.append(", port=").append(port); + sb.append(", starttls=").append(startTls); + sb.append(", userName='").append(userName).append('\''); + sb.append(", password='").append(password).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java new file mode 100644 index 0000000..419c519 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java @@ -0,0 +1,336 @@ +package de.thpeetz.kontor.admin; + +import de.thpeetz.kontor.admin.data.*; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.admin.services.MetaDataService; +import de.thpeetz.kontor.mailclient.data.MailAccount; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; + +@Slf4j +@Component +public class SetupModuleAdmin implements ApplicationListener { + boolean alreadySetup = false; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthorizationMatrixRepository authorizationMatrixRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private MailAccountRepository mailAccountRepository; + + @Autowired + private MailProperties mailProperties; + + @Autowired + private AdminService adminService; + + @Autowired + private MetaDataService metaDataService; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (alreadySetup) { + return; + } + + // Create initial roles and users + Role adminRole = adminService.addRole("ROLE_ADMIN"); + Role userRole = adminService.addRole("ROLE_USER"); + List users = userRepository.findAll(); + if (users.isEmpty()) { + User adminUser = initAdminUser(); + initMatrix(adminRole, adminUser); + initMatrix(userRole, adminUser); + } + log.info("MailProperties: {}", mailProperties); + initMail(mailProperties); + + initMetaData(); + } + + private void initMail(MailProperties mailProperties) { + log.info("initMail: Host {} with User {}", mailProperties.getHost(), mailProperties.getUserName()); + if (mailProperties.getHost() == null || mailProperties.getHost().isEmpty()) { + return; + } + boolean addMailAccount = false; + List mailAccounts = mailAccountRepository.findAll(); + if (mailAccounts.isEmpty()) { + addMailAccount = true; + } + for (MailAccount mailAccount : mailAccounts) { + String accountUser = mailAccount.getUserName(); + String propertyUser = mailProperties.getUserName(); + String accountHost = mailAccount.getHost(); + String propertyHost = mailProperties.getHost(); + if (propertyHost.equals(accountHost) && propertyUser.equals(accountUser)) { + log.debug("already configured: {}", mailAccount); + } else { + addMailAccount = true; + } + } + if (addMailAccount) { + log.info("add Mail Account: {}", mailProperties); + MailAccount mailAccount = new MailAccount(); + mailAccount.setProtocol(mailProperties.getProtocol()); + mailAccount.setHost(mailProperties.getHost()); + mailAccount.setPort(mailProperties.getPort()); + mailAccount.setUserName(mailProperties.getUserName()); + mailAccount.setPassword(mailProperties.getPassword()); + mailAccount.setStartTls(mailProperties.getStartTls()); + mailAccountRepository.save(mailAccount); + } + } + + private void initMatrix(Role role, User user) { + log.info("initMatrix: Role {} for User {}", role.getName(), user.getUserName()); + Collection configuredRoles = authorizationMatrixRepository.findByUser(user); + if (configuredRoles.stream().anyMatch(matrix -> matrix.getRole().getId().equals(role.getId()))) { + log.info("Role {} already defined", role.getName()); + } else { + log.info("add Role {} to User {}", role.getName(), user.getUserName()); + final AuthorizationMatrix adminMatrix = new AuthorizationMatrix(); + adminMatrix.setUser(user); + adminMatrix.setRole(role); + authorizationMatrixRepository.save(adminMatrix); + } + } + + private User initAdminUser() { + log.info("initAdminUser"); + User admin = userRepository.findByUserName("admin"); + if (admin == null) { + log.info("User admin not found, will be created."); + admin = new User(); + admin.setFirstName("Admin"); + admin.setLastName("Administrator"); + admin.setUserName("admin"); + admin.setEmail("admin@example.org"); + admin.setPassword(passwordEncoder.encode("admin")); + userRepository.save(admin); + } + return admin; + } + + private void initMetaData() { + log.info("initMetaData"); + MetaDataTable mediaArticleTable = metaDataService.getTable("media_article"); + metaDataService.getColumn(mediaArticleTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(mediaArticleTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(mediaArticleTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(mediaArticleTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(mediaArticleTable, "url", "link_url", "TEXT", "UNIQUE", 5); + metaDataService.getColumn(mediaArticleTable, "review", "review", "BOOLEAN", null, 6); + metaDataService.getColumn(mediaArticleTable, "title", "title", "TEXT", null, 7); + MetaDataTable mediaVideoTable = metaDataService.getTable("media_video"); + metaDataService.getColumn(mediaVideoTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(mediaVideoTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(mediaVideoTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(mediaVideoTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(mediaVideoTable, "url", "link_url", "TEXT", "UNIQUE", 5); + metaDataService.getColumn(mediaVideoTable, "review", "review", "BOOLEAN", null, 6); + metaDataService.getColumn(mediaVideoTable, "should_download", "should_download", "BOOLEAN", null, 7); + metaDataService.getColumn(mediaVideoTable, "title", "title", "TEXT", null, 8); + metaDataService.getColumn(mediaVideoTable, "file_name", "file_name", "TEXT", null, 9); + metaDataService.getColumn(mediaVideoTable, "path", "path", "TEXT", null, 10); + metaDataService.getColumn(mediaVideoTable, "cloud_link", "cloud_link", "TEXT", null, 11); + MetaDataTable mediaFileTable = metaDataService.getTable("media_file"); + metaDataService.getColumn(mediaFileTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(mediaFileTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(mediaFileTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(mediaFileTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(mediaFileTable, "url", "link_url", "TEXT", "UNIQUE", 5); + metaDataService.getColumn(mediaFileTable, "review", "review", "BOOLEAN", null, 6); + metaDataService.getColumn(mediaFileTable, "should_download", "should_download", "BOOLEAN", null, 7); + metaDataService.getColumn(mediaFileTable, "title", "title", "TEXT", null, 8); + metaDataService.getColumn(mediaFileTable, "file_name", "file_name", "TEXT", null, 9); + metaDataService.getColumn(mediaFileTable, "path", "path", "TEXT", null, 10); + metaDataService.getColumn(mediaFileTable, "cloud_link", "cloud_link", "TEXT", null, 11); + MetaDataTable artistTable = metaDataService.getTable("artist"); + metaDataService.getColumn(artistTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(artistTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(artistTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(artistTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(artistTable, "name", "name", "TEXT", "UNIQUE", 5); + MetaDataTable publisherTable = metaDataService.getTable("publisher"); + metaDataService.getColumn(publisherTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(publisherTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(publisherTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(publisherTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(publisherTable, "name", "name", "TEXT", "UNIQUE", 5); + MetaDataTable comicTable = metaDataService.getTable("comic"); + metaDataService.getColumn(comicTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(comicTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(comicTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(comicTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(comicTable, "completed", "completed", "BOOLEAN", null, 5); + metaDataService.getColumn(comicTable, "current_order", "current_order", "BOOLEAN", null, 6); + metaDataService.getColumn(comicTable, "title", "title", "TEXT", "UNIQUE", 7); + metaDataService.getColumn(comicTable, "publisher_id", "publisher_id", "TEXT", null, 8); + MetaDataTable issueTable = metaDataService.getTable("issue"); + metaDataService.getColumn(issueTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(issueTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(issueTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(issueTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(issueTable, "in_stock", "in_stock", "BOOLEAN", null, 5); + metaDataService.getColumn(issueTable, "is_read", "is_read", "BOOLEAN", null, 6); + metaDataService.getColumn(issueTable, "issue_number", "issue_number", "TEXT", null, 7); + metaDataService.getColumn(issueTable, "comic_id", "comic_id", "TEXT", null, 8); + metaDataService.getColumn(issueTable, "volume_id", "volume_id", "TEXT", null, 9); + MetaDataTable volumeTable = metaDataService.getTable("volume"); + metaDataService.getColumn(volumeTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(volumeTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(volumeTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(volumeTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(volumeTable, "name", "name", "TEXT", null, 5); + metaDataService.getColumn(volumeTable, "comic_id", "comic_id", "TEXT", null, 6); + MetaDataTable tpbTable = metaDataService.getTable("trade_paperback"); + metaDataService.getColumn(tpbTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(tpbTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(tpbTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(tpbTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(tpbTable, "issue_start", "issue_start", "LONG", null, 5); + metaDataService.getColumn(tpbTable, "issue_end", "issue_end", "LONG", null, 6); + metaDataService.getColumn(tpbTable, "name", "name", "TEXT", null, 7); + metaDataService.getColumn(tpbTable, "comic_id", "comic_id", "TEXT", null, 8); + MetaDataTable storyArcTable = metaDataService.getTable("story_arc"); + metaDataService.getColumn(storyArcTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(storyArcTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(storyArcTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(storyArcTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(storyArcTable, "name", "name", "TEXT", null, 5); + metaDataService.getColumn(storyArcTable, "comic_id", "comic_id", "TEXT", null, 6); + MetaDataTable worktypeTable = metaDataService.getTable("worktype"); + metaDataService.getColumn(worktypeTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(worktypeTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(worktypeTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(worktypeTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(worktypeTable, "name", "name", "TEXT", "UNIQUE", 5); + MetaDataTable comicworkTable = metaDataService.getTable("comic_work"); + metaDataService.getColumn(comicworkTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(comicworkTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(comicworkTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(comicworkTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(comicworkTable, "artist_id", "artist_id", "TEXT", null, 5); + metaDataService.getColumn(comicworkTable, "comic_id", "comic_id", "TEXT", null, 6); + metaDataService.getColumn(comicworkTable, "work_type_id", "work_type_id", "TEXT", null, 7); + MetaDataTable authorTable = metaDataService.getTable("author"); + metaDataService.getColumn(authorTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(authorTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(authorTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(authorTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(authorTable, "first_name", "first_name", "TEXT", null, 5); + metaDataService.getColumn(authorTable, "last_name", "last_name", "TEXT", null, 6); + MetaDataTable articleTable = metaDataService.getTable("article"); + metaDataService.getColumn(articleTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(articleTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(articleTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(articleTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(articleTable, "title", "title", "TEXT", "UNIQUE", 5); + MetaDataTable articleAuthorTable = metaDataService.getTable("article_author"); + metaDataService.getColumn(articleAuthorTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(articleAuthorTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(articleAuthorTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(articleAuthorTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(articleAuthorTable, "article_id", "article_id", "TEXT", null, 5); + metaDataService.getColumn(articleAuthorTable, "author_id", "author_id", "TEXT", null, 6); + MetaDataTable bookTable = metaDataService.getTable("book"); + metaDataService.getColumn(bookTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(bookTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(bookTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(bookTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(bookTable, "isbn", "isbn", "TEXT", "UNIQUE", 5); + metaDataService.getColumn(bookTable, "title", "title", "TEXT", null, 6); + metaDataService.getColumn(bookTable, "year", "year", "LONG", null, 7); + metaDataService.getColumn(bookTable, "publisher_id", "publisher_id", "TEXT", null, 8); + MetaDataTable bookAuthorTable = metaDataService.getTable("book_author"); + metaDataService.getColumn(bookAuthorTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(bookAuthorTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(bookAuthorTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(bookAuthorTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(bookAuthorTable, "author_id", "author_id", "TEXT", null, 5); + metaDataService.getColumn(bookAuthorTable, "book_id", "book_id", "TEXT", null, 6); + MetaDataTable bookshelfPublisherTable = metaDataService.getTable("bookshelf_publisher"); + metaDataService.getColumn(bookshelfPublisherTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(bookshelfPublisherTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(bookshelfPublisherTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(bookshelfPublisherTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(bookshelfPublisherTable, "name", "name", "TEXT", "UNIQUE", 5); + MetaDataTable sportTable = metaDataService.getTable("sport"); + metaDataService.getColumn(sportTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(sportTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(sportTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(sportTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(sportTable, "name", "name", "TEXT", "UNIQUE", 5); + MetaDataTable playerTable = metaDataService.getTable("player"); + metaDataService.getColumn(playerTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(playerTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(playerTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(playerTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(playerTable, "first_name", "first_name", "TEXT", null, 5); + metaDataService.getColumn(playerTable, "last_name", "last_name", "TEXT", null, 6); + MetaDataTable teamTable = metaDataService.getTable("team"); + metaDataService.getColumn(teamTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(teamTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(teamTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(teamTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(teamTable, "name", "name", "TEXT", "UNIQUE", 5); + metaDataService.getColumn(teamTable, "short_name", "short_name", "TEXT", null, 6); + metaDataService.getColumn(teamTable, "sport_id", "sport_id", "TEXT", null, 7); + MetaDataTable vendorTable = metaDataService.getTable("vendor"); + metaDataService.getColumn(vendorTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(vendorTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(vendorTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(vendorTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(vendorTable, "name", "name", "TEXT", "UNIQUE", 5); + MetaDataTable fieldPositionTable = metaDataService.getTable("field_position"); + metaDataService.getColumn(fieldPositionTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(fieldPositionTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(fieldPositionTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(fieldPositionTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(fieldPositionTable, "name", "name", "TEXT", null, 5); + metaDataService.getColumn(fieldPositionTable, "short_name", "short_name", "TEXT", null, 6); + metaDataService.getColumn(fieldPositionTable, "sport_id", "sport_id", "TEXT", null, 7); + MetaDataTable roosterTable = metaDataService.getTable("rooster"); + metaDataService.getColumn(roosterTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(roosterTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(roosterTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(roosterTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(roosterTable, "year", "year", "LONG", null, 5); + metaDataService.getColumn(roosterTable, "player_id", "player_id", "TEXT", null, 6); + metaDataService.getColumn(roosterTable, "position_id", "position_id", "TEXT", null, 7); + metaDataService.getColumn(roosterTable, "team_id", "team_id", "TEXT", null, 8); + MetaDataTable cardSetTable = metaDataService.getTable("card_set"); + metaDataService.getColumn(cardSetTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(cardSetTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(cardSetTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(cardSetTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(cardSetTable, "insert_set", "insert_set", "BOOLEAN", null, 5); + metaDataService.getColumn(cardSetTable, "parallel_set", "parallel_set", "BOOLEAN", null, 6); + metaDataService.getColumn(cardSetTable, "name", "name", "TEXT", null, 7); + metaDataService.getColumn(cardSetTable, "vendor_id", "vendor_id", "TEXT", null, 8); + MetaDataTable cardTable = metaDataService.getTable("card"); + metaDataService.getColumn(cardTable, "id", "identifier", "TEXT", "PRIMARY KEY", 1); + metaDataService.getColumn(cardTable, "created_date", "created", "TIMESTAMP", null, 2); + metaDataService.getColumn(cardTable, "last_modified_date", "modified", "TIMESTAMP", null, 3); + metaDataService.getColumn(cardTable, "version", "version", "LONG", null, 4); + metaDataService.getColumn(cardTable, "card_number", "card_number", "LONG", null, 5); + metaDataService.getColumn(cardTable, "year", "year", "LONG", null, 6); + metaDataService.getColumn(cardTable, "card_set_id", "card_set_id", "TEXT", null, 7); + metaDataService.getColumn(cardTable, "rooster_id", "rooster_id", "TEXT", null, 8); + metaDataService.getColumn(cardTable, "vendor_id", "vendor_id", "TEXT", null, 9); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrix.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrix.java new file mode 100644 index 0000000..26b5ebb --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrix.java @@ -0,0 +1,35 @@ +package de.thpeetz.kontor.admin.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@Entity +public class AuthorizationMatrix extends AbstractEntity { + + @ManyToOne + @JoinColumn(name = "user_id") + @NotNull + private User user; + + @ManyToOne + @JoinColumn(name = "role_id") + @NotNull + private Role role; + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("AuthorizationMatrix{"); + sb.append("user=").append(user.getUserName()); + sb.append(", role=").append(role.getName()); + sb.append('}'); + return sb.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrixRepository.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrixRepository.java new file mode 100644 index 0000000..25d2fb9 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/AuthorizationMatrixRepository.java @@ -0,0 +1,13 @@ +package de.thpeetz.kontor.admin.data; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuthorizationMatrixRepository extends JpaRepository { + + List findByUser(User user); + + List findByRole(Role role); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/MailAccountRepository.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MailAccountRepository.java new file mode 100644 index 0000000..ffe66ae --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MailAccountRepository.java @@ -0,0 +1,8 @@ +package de.thpeetz.kontor.admin.data; + +import de.thpeetz.kontor.mailclient.data.MailAccount; +import org.springframework.data.jpa.repository.JpaRepository; + + +public interface MailAccountRepository extends JpaRepository { +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumn.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumn.java new file mode 100644 index 0000000..f09f087 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumn.java @@ -0,0 +1,37 @@ +package de.thpeetz.kontor.admin.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Table( + indexes = @Index(columnList = "columnName, table_id"), + uniqueConstraints = @UniqueConstraint(columnNames = {"table_id", "columnOrder"}) +) +public class MetaDataColumn extends AbstractEntity { + + @NotNull + private String columnName; + + private String columnSyncName; + + private String columnType; + + @Nullable + private String columnModifier; + + private Integer columnOrder; + + private Boolean isShown; + + @ManyToOne + @JoinColumn(name = "table_id") + @NotNull + private MetaDataTable table; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumnRepository.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumnRepository.java new file mode 100644 index 0000000..8959dd2 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataColumnRepository.java @@ -0,0 +1,10 @@ +package de.thpeetz.kontor.admin.data; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MetaDataColumnRepository extends JpaRepository { + + List findByTable(MetaDataTable table); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTable.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTable.java new file mode 100644 index 0000000..639666d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTable.java @@ -0,0 +1,26 @@ +package de.thpeetz.kontor.admin.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.LinkedList; +import java.util.List; + +@Entity +@Getter +@Setter +@Table( + indexes = @Index(columnList = "tableName"), + uniqueConstraints = @UniqueConstraint(columnNames = {"tableName"}) +) +public class MetaDataTable extends AbstractEntity { + + @NotNull + private String tableName; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "table") + private List tableColumns = new LinkedList<>(); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTableRepository.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTableRepository.java new file mode 100644 index 0000000..694ee52 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/MetaDataTableRepository.java @@ -0,0 +1,8 @@ +package de.thpeetz.kontor.admin.data; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MetaDataTableRepository extends JpaRepository { + + MetaDataTable findByTableName(String tableName); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleData.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleData.java new file mode 100644 index 0000000..b884425 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleData.java @@ -0,0 +1,25 @@ +package de.thpeetz.kontor.admin.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Table( + indexes = @Index(columnList = "moduleName"), + uniqueConstraints = @UniqueConstraint(columnNames = {"moduleName"}) +) +public class ModuleData extends AbstractEntity { + + @NotEmpty + private String moduleName; + + private Boolean importData; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleDataRepository.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleDataRepository.java new file mode 100644 index 0000000..484dde3 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/ModuleDataRepository.java @@ -0,0 +1,15 @@ +package de.thpeetz.kontor.admin.data; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ModuleDataRepository extends JpaRepository { + + @Query("select m from ModuleData m where lower(m.moduleName) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + ModuleData findByModuleName(String moduleName); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/Role.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/Role.java new file mode 100644 index 0000000..5ad81c2 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/Role.java @@ -0,0 +1,30 @@ +package de.thpeetz.kontor.admin.data; + +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +@Slf4j +@Getter +@Setter +@ToString +@Entity +public class Role extends AbstractEntity { + + @NotEmpty + private String name; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "role") + @Nullable + private List matrix; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/RoleRepository.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/RoleRepository.java new file mode 100644 index 0000000..932ae29 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/RoleRepository.java @@ -0,0 +1,18 @@ +package de.thpeetz.kontor.admin.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RoleRepository extends JpaRepository { + + @Query("select r from Role r " + + "where lower(r.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + @Query("select r from Role r " + + "where lower(r.name) like lower(:name) ") + Role findByName(@Param("name") String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/User.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/User.java new file mode 100644 index 0000000..a80b6bc --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/User.java @@ -0,0 +1,62 @@ +package de.thpeetz.kontor.admin.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.util.LinkedList; +import java.util.List; + +@Slf4j +@Getter +@Setter +@ToString +@Entity +@Table(indexes = @Index(columnList = "userName"), uniqueConstraints = @UniqueConstraint(columnNames = {"userName"})) +public class User extends AbstractEntity { + + private String firstName; + + private String lastName; + + @NotEmpty + private String userName; + + private String email; + + private String password; + + private boolean enabled; + + private boolean tokenExpired; + + private String token; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "user") + @Nullable + private List matrix = new LinkedList<>(); + + public String getFullName() { + StringBuilder fullNamBuilder = new StringBuilder(); + if (firstName != null) { + fullNamBuilder.append(firstName); + } + if (lastName != null) { + if (fullNamBuilder.length() > 0) { + fullNamBuilder.append(" "); + } + fullNamBuilder.append(lastName); + } + return fullNamBuilder.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/UserRepository.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/UserRepository.java new file mode 100644 index 0000000..21d93e0 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/UserRepository.java @@ -0,0 +1,16 @@ +package de.thpeetz.kontor.admin.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserRepository extends JpaRepository { + + @Query("select u from User u " + + "where lower(u.lastName) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + User findByUserName(String userName); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/services/AdminService.java b/springboot/src/main/java/de/thpeetz/kontor/admin/services/AdminService.java new file mode 100644 index 0000000..77a6448 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/services/AdminService.java @@ -0,0 +1,110 @@ +package de.thpeetz.kontor.admin.services; + +import java.util.Collection; +import java.util.List; + +import org.springframework.stereotype.Service; + +import de.thpeetz.kontor.admin.data.AuthorizationMatrix; +import de.thpeetz.kontor.admin.data.AuthorizationMatrixRepository; +import de.thpeetz.kontor.admin.data.Role; +import de.thpeetz.kontor.admin.data.RoleRepository; +import de.thpeetz.kontor.admin.data.User; +import de.thpeetz.kontor.admin.data.UserRepository; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class AdminService { + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final AuthorizationMatrixRepository authorizationMatrixRepository; + + public AdminService(UserRepository userRepository, RoleRepository roleRepository, + AuthorizationMatrixRepository authorizationMatrixRepository) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.authorizationMatrixRepository = authorizationMatrixRepository; + } + + public List findAllUsers() { + return userRepository.findAll(); + } + + public List findAllRoles() { + return roleRepository.findAll(); + } + + public Collection findAllRoles(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return roleRepository.findAll(); + } else { + return roleRepository.search(stringFilter); + } + } + + public Role addRole(String roleName) { + Role role = roleRepository.findByName(roleName); + if (role == null) { + log.info("Role {} was not found, will create it.", roleName); + role = new Role(); + role.setName(roleName); + roleRepository.save(role); + } + return role; + } + + public void saveRole(Role role) { + if (role == null) { + log.warn("Role is null. Can't save it."); + } + log.info("saveRole: role={}", role); + roleRepository.save(role); + } + + public void deleteRole(Role role) { + if (role == null) { + log.warn("Role is null. Can't delete it."); + } + log.info("deleteRole: role={}", role); + roleRepository.delete(role); + } + + public List findAllAuthorizationMatrices() { + return authorizationMatrixRepository.findAll(); + } + + public void saveAuthorizationMatrix(AuthorizationMatrix authorizationMatrix) { + if (authorizationMatrix == null) { + log.warn("AuthorizationMatrix is null. Can't save it."); + } + log.info("saveAuthorizationMatrix: authorizationMatrix={}", authorizationMatrix); + authorizationMatrixRepository.save(authorizationMatrix); + } + + public void deleteAuthorizationMatrix(AuthorizationMatrix authorizationMatrix) { + if (authorizationMatrix == null) { + log.warn("AuthorizationMatrix is null. Can't delete it."); + } + log.info("deleteAuthorizationMatrix: authorizationMatrix={}", authorizationMatrix); + authorizationMatrixRepository.delete(authorizationMatrix); + } + + public String getUserFullName(String userName) { + log.debug("get Fullname für user {}", userName); + User user = userRepository.findByUserName(userName); + if (user == null) { + log.info("keinen Eintrag für {} gefunden", userName); + return userName; + } else { + log.info("Voller Name des User {}: {}", userName, user.getFullName()); + return user.getFullName(); + } + } + + public User getUser(String userName) { + log.debug("get User {}", userName); + return userRepository.findByUserName(userName); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/services/KontorUserDetailsService.java b/springboot/src/main/java/de/thpeetz/kontor/admin/services/KontorUserDetailsService.java new file mode 100644 index 0000000..f76d6df --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/services/KontorUserDetailsService.java @@ -0,0 +1,125 @@ +package de.thpeetz.kontor.admin.services; + +import de.thpeetz.kontor.admin.data.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.math.BigInteger; +import java.security.SecureRandom; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service("userDetailsService") +public class KontorUserDetailsService implements UserDetailsService { + + private static SecureRandom random = new SecureRandom(); + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoleRepository roleRepository; + + @Autowired + private AuthorizationMatrixRepository authorizationMatrixRepository; + + public Collection findAllUsers(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return userRepository.findAll(); + } else { + return userRepository.search(stringFilter); + } + } + + public void saveUser(User user) { + if (user == null) { + log.warn("User is null. Can't save it."); + } + log.info("saveUser: user={}", user); + userRepository.save(user); + } + + public void saveUser(User user, List roles) { + if (user == null) { + log.warn("User is null. Can't save it."); + } + log.info("First save user: {}", user); + user = userRepository.save(user); + List copy = roles.stream().collect(Collectors.toList()); + List permissions = user.getMatrix(); + permissions.forEach(matrix -> { + if (roles.contains(matrix.getRole())) { + log.info("Role {} already assigned", matrix.getRole()); + copy.remove(matrix.getRole()); + } else { + log.info("Role {} has to be removed", matrix.getRole()); + authorizationMatrixRepository.delete(matrix); + } + }); + log.info("remaining roles: {}", copy); + for (Role role : copy) { + AuthorizationMatrix matrix = new AuthorizationMatrix(); + matrix.setUser(user); + matrix.setRole(role); + authorizationMatrixRepository.save(matrix); + } + } + + public void deleteUser(User user) { + if (user == null) { + log.warn("User is null. Can't delete it."); + } + log.info("deleteUser: user={}", user); + userRepository.delete(user); + } + + @Override + public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { + + log.info("loadUserByUsername: userName={}", userName); + User user = userRepository.findByUserName(userName); + if (user == null) { + log.info("User not found"); + return null; + } + + Collection authorities = getAuthorities(user); + log.info("User {} hat Rolen: {}", userName, authorities); + return new org.springframework.security.core.userdetails.User(user.getUserName(), user.getPassword(), + authorities); + } + + private Collection getAuthorities(User user) { + return authorizationMatrixRepository.findByUser(user).stream() + .map(matrix -> matrix.getRole().getName()) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + public String getRememberedUser(String id) { + log.info("getRememberedUser: id={}", id); + return "admin"; + } + + public String rememberUser(String username) { + String randomId = new BigInteger(130, random).toString(32); + log.info("rememberUser: username={}", username); + return randomId; + } + + public void removeRememberedUser(String id) { + log.info("removeRememberedUser: id={}", id); + } + + public List findAllRoles() { + return roleRepository.findAll(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/services/MailService.java b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MailService.java new file mode 100644 index 0000000..dbe9270 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MailService.java @@ -0,0 +1,24 @@ +package de.thpeetz.kontor.admin.services; + +import de.thpeetz.kontor.mailclient.data.MailAccount; +import de.thpeetz.kontor.admin.data.MailAccountRepository; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class MailService { + + private final MailAccountRepository mailAccountRepository; + + public MailService(MailAccountRepository mailAccountRepository) { + this.mailAccountRepository = mailAccountRepository; + } + + public List findAllMailAccounts() { + return mailAccountRepository.findAll(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java new file mode 100644 index 0000000..293c4d4 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java @@ -0,0 +1,101 @@ +package de.thpeetz.kontor.admin.services; + +import org.springframework.stereotype.Service; + +import de.thpeetz.kontor.admin.data.MetaDataColumn; +import de.thpeetz.kontor.admin.data.MetaDataColumnRepository; +import de.thpeetz.kontor.admin.data.MetaDataTable; +import de.thpeetz.kontor.admin.data.MetaDataTableRepository; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +@Service +public class MetaDataService { + + private final MetaDataTableRepository metaDataTableRepository; + + private final MetaDataColumnRepository metaDataColumnRepository; + + public MetaDataService(MetaDataTableRepository metaDataTableRepository, MetaDataColumnRepository metaDataColumnRepository) { + this.metaDataTableRepository = metaDataTableRepository; + this.metaDataColumnRepository = metaDataColumnRepository; + } + + public MetaDataTable getTable(String tableName) { + MetaDataTable table = metaDataTableRepository.findByTableName(tableName); + if (table == null) { + log.info("Metadata for table {} not found, will create it", tableName); + table = new MetaDataTable(); + table.setTableName(tableName); + metaDataTableRepository.save(table); + } + return table; + } + + public void getColumn(MetaDataTable table, String columnName, String columnSyncName, String columnType, String columnModifier, Integer columnOrder) { + if (table.getTableColumns().stream().anyMatch(column -> column.getColumnName().equals(columnName))) { + log.info("Column {} with name {} of table {} found, check Values", columnOrder, columnName, table.getTableName()); + MetaDataColumn column = table.getTableColumns().get(columnOrder.intValue()-1); + if (!column.getColumnName().equals(columnName)) { + log.debug("columnName has to be changed to {}", columnName); + column.setColumnName(columnName); + } + if (!column.getColumnSyncName().equals(columnSyncName)) { + log.debug("columnSyncName has to be changed to {}", columnSyncName); + column.setColumnSyncName(columnSyncName); + } + if (!column.getColumnType().equals(columnType)) { + log.debug("columnType has to be changed to {}", columnType); + column.setColumnType(columnType); + } + if (columnModifier != null && !column.getColumnModifier().equals(columnModifier)) { + log.debug("columnModifier has to be changed to {}", columnModifier); + column.setColumnModifier(columnModifier); + } + if (column.getIsShown() == null) { + log.debug("isShown set to false"); + column.setIsShown(Boolean.FALSE); + } + metaDataColumnRepository.save(column); + } else { + log.info("Column {} of table {} not found, will create it", columnName, table.getTableName()); + MetaDataColumn column = new MetaDataColumn(); + column.setTable(table); + column.setColumnName(columnName); + column.setColumnSyncName(columnSyncName); + column.setColumnType(columnType); + column.setColumnModifier(columnModifier); + column.setColumnOrder(columnOrder); + column.setIsShown(Boolean.FALSE); + metaDataColumnRepository.save(column); + } + } + + public List findAllMetaDataColumns() { + return metaDataColumnRepository.findAll(); + } + + public void deleteMetaDataColumn(MetaDataColumn metaDataColumn) { + if (metaDataColumn == null) { + log.warn("MetaDataColumn is null, can't delete it"); + return; + } + log.debug("deleteMetaDataColumn: MetaDataColumn={}", metaDataColumn); + metaDataColumnRepository.delete(metaDataColumn); + } + + public void saveMetaDataColumn(MetaDataColumn metaDataColumn) { + if (metaDataColumn == null) { + log.warn("MetaDataColumn is null, can't save it"); + return; + } + log.debug("saveMetaDataColumn: MetaDataColumn={}", metaDataColumn); + metaDataColumnRepository.save(metaDataColumn); + } + + public List findAllTables() { + return metaDataTableRepository.findAll(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/services/ModuleService.java b/springboot/src/main/java/de/thpeetz/kontor/admin/services/ModuleService.java new file mode 100644 index 0000000..7596701 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/services/ModuleService.java @@ -0,0 +1,76 @@ +package de.thpeetz.kontor.admin.services; + +import de.thpeetz.kontor.admin.data.ModuleData; +import de.thpeetz.kontor.admin.data.ModuleDataRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +public class ModuleService { + + private final ModuleDataRepository moduleDataRepository; + + public ModuleService(ModuleDataRepository moduleDataRepository) { + this.moduleDataRepository = moduleDataRepository; + } + + public List findAll(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return moduleDataRepository.findAll(); + } else { + return moduleDataRepository.search(stringFilter); + } + } + + public ModuleData findByName(String moduleName) { + if (moduleName == null || moduleName.isEmpty()) { + return null; + } else { + return moduleDataRepository.findByModuleName(moduleName); + } + } + + public boolean importData(String moduleName) { + ModuleData module = moduleDataRepository.findByModuleName(moduleName); + if (module != null) { + return module.getImportData(); + } else { + log.info("Module {} not found, should import data", moduleName); + return true; + } + } + + public void setDataImported(String moduleName) { + ModuleData module = moduleDataRepository.findByModuleName(moduleName); + if (module == null) { + log.info("Module {} not found, will create it", moduleName); + module = new ModuleData(); + module.setModuleName(moduleName); + module.setImportData(false); + moduleDataRepository.save(module); + } else { + log.info("Module {} found, change import data", module); + module.setImportData(false); + moduleDataRepository.save(module); + } + } + + public void saveModuleData(ModuleData moduleData) { + if (moduleData == null) { + log.warn("ModuleData is null, can't save it."); + } else { + moduleDataRepository.save(moduleData); + } + } + + public void deleteModuleData(ModuleData moduleData) { + if (moduleData == null) { + log.warn("ModuleData is null, can't delete it."); + } else { + moduleDataRepository.delete(moduleData); + } + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/AdminLayout.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/AdminLayout.java new file mode 100644 index 0000000..5ed32c5 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/AdminLayout.java @@ -0,0 +1,36 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.theme.lumo.LumoUtility; + +import de.thpeetz.kontor.admin.AdminConstants; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.common.views.KontorLayoutUtil; +import de.thpeetz.kontor.security.SecurityService; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class AdminLayout extends AppLayout { + + private final AdminService adminService; + + private final SecurityService securityService; + + public AdminLayout(AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + + KontorLayoutUtil layout = new KontorLayoutUtil(this, adminService, securityService); + layout.setSecondaryNavigation(getSecondaryNavigation()); + layout.createHeader(AdminConstants.ADMIN_TITLE); + } + + private HorizontalLayout getSecondaryNavigation() { + HorizontalLayout navigation = new HorizontalLayout(); + navigation.addClassNames(LumoUtility.JustifyContent.CENTER, LumoUtility.Gap.SMALL, LumoUtility.Height.MEDIUM); + navigation.add(AdminConstants.getUserNavigation(), AdminConstants.getRoleNavigation(), + AdminConstants.getAuthorizationNavigation()); + return navigation; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationForm.java new file mode 100644 index 0000000..da593f8 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationForm.java @@ -0,0 +1,112 @@ +package de.thpeetz.kontor.admin.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.admin.data.AuthorizationMatrix; +import de.thpeetz.kontor.admin.data.Role; +import de.thpeetz.kontor.admin.data.User; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class AuthorizationForm extends FormLayout { + + ComboBox user = new ComboBox<>("User"); + ComboBox role = new ComboBox<>("Role"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(AuthorizationMatrix.class); + + public AuthorizationForm(List users, List roles) { + addClassName("authorizationmatrix-form"); + binder.bindInstanceFields(this); + + user.setItems(users); + user.setItemLabelGenerator(User::getUserName); + role.setItems(roles); + role.setItemLabelGenerator(Role::getName); + add(user, role, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setAuthorizationMatrix(AuthorizationMatrix authorizationMatrix) { + binder.setBean(authorizationMatrix); + } + + public abstract static class AuthorizationFormEvent extends ComponentEvent { + private AuthorizationMatrix authorizationMatrix; + + protected AuthorizationFormEvent(AuthorizationForm source, AuthorizationMatrix authorizationMatrix) { + super(source, false); + this.authorizationMatrix = authorizationMatrix; + } + + public AuthorizationMatrix getAuthorizationMatrix() { + return authorizationMatrix; + } + } + + public static class SaveEvent extends AuthorizationFormEvent { + SaveEvent(AuthorizationForm source, AuthorizationMatrix authorizationMatrix) { + super(source, authorizationMatrix); + } + } + + public static class DeleteEvent extends AuthorizationFormEvent { + DeleteEvent(AuthorizationForm source, AuthorizationMatrix authorizationMatrix) { + super(source, authorizationMatrix); + } + } + + public static class CloseEvent extends AuthorizationFormEvent { + CloseEvent(AuthorizationForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationView.java new file mode 100644 index 0000000..435ae8f --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/AuthorizationView.java @@ -0,0 +1,114 @@ +package de.thpeetz.kontor.admin.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; + +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.admin.AdminConstants; +import de.thpeetz.kontor.admin.data.AuthorizationMatrix; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringComponent +@Scope("prototype") +@RolesAllowed("ROLE_ADMIN") +@Route(value = AdminConstants.AUTHORIZATION_ROUTE, layout = MainLayout.class) +@PageTitle("Authorization | Admin | Kontor") +public class AuthorizationView extends VerticalLayout { + + Grid grid = new Grid<>(AuthorizationMatrix.class); + AuthorizationForm form; + AdminService service; + + public AuthorizationView(AdminService service) { + this.service = service; + addClassName("authoriaztionmatrix-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("authorizationmatrix-grid"); + grid.setSizeFull(); + grid.setColumns("user.userName", "role.name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editAuthorizationMatrix(event.getValue())); + } + + private void configureForm() { + form = new AuthorizationForm(service.findAllUsers(), service.findAllRoles()); + form.setWidth("25em"); + form.addSaveListener(this::saveAuthorizationMatrix); + form.addDeleteListener(this::deleteAuthorizationMatrix); + form.addCloseListener(e -> closeEditor()); + } + + private void saveAuthorizationMatrix(AuthorizationForm.SaveEvent event) { + AuthorizationMatrix authorizationMatrix = event.getAuthorizationMatrix(); + service.saveAuthorizationMatrix(authorizationMatrix); + updateList(); + closeEditor(); + } + + private void deleteAuthorizationMatrix(AuthorizationForm.DeleteEvent event) { + service.deleteAuthorizationMatrix(event.getAuthorizationMatrix()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + Button addAuthorizationMaxtrixButton = new Button("Add permssion", click -> addAuthorizationMatrix()); + HorizontalLayout toolbar = new HorizontalLayout(addAuthorizationMaxtrixButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editAuthorizationMatrix(AuthorizationMatrix authorizationMatrix) { + if (authorizationMatrix == null) { + closeEditor(); + } else { + form.setAuthorizationMatrix(authorizationMatrix); + form.setVisible(true); + addClassName("editing"); + } + } + + public void closeEditor() { + form.setAuthorizationMatrix(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addAuthorizationMatrix() { + grid.asSingleSelect().clear(); + editAuthorizationMatrix(new AuthorizationMatrix()); + } + + private void updateList() { + grid.setItems(service.findAllAuthorizationMatrices()); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/LoginView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/LoginView.java new file mode 100644 index 0000000..286bc03 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/LoginView.java @@ -0,0 +1,51 @@ +package de.thpeetz.kontor.admin.views; + +import org.springframework.security.core.context.SecurityContextHolder; + +import com.vaadin.flow.component.html.H1; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.login.LoginForm; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.BeforeEnterObserver; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Route("login") +@PageTitle("Login | Vaadin Kontor") +@AnonymousAllowed +public class LoginView extends VerticalLayout implements BeforeEnterObserver { + + private final LoginForm login = new LoginForm(); + + public LoginView() { + addClassName("login-view"); + setSizeFull(); + setAlignItems(Alignment.CENTER); + setJustifyContentMode(JustifyContentMode.CENTER); + + login.setAction("login"); + + add(new H1("Vaadin Kontor")); + add(new Span("Username: user, Password: password")); + add(new Span("Username: admin, Password: password")); + add(login); + } + + @Override + public void beforeEnter(BeforeEnterEvent beforeEnterEvent) { + log.info("beforeEnter: {}", beforeEnterEvent.getLocation()); + log.info("beforeEnter: {}", SecurityContextHolder.getContext().getAuthentication()); + // inform the user about an authentication error + if (beforeEnterEvent.getLocation() + .getQueryParameters() + .getParameters() + .containsKey("error")) { + login.setError(true); + } + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java new file mode 100644 index 0000000..f70c161 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java @@ -0,0 +1,113 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.admin.data.MetaDataColumn; +import de.thpeetz.kontor.admin.data.MetaDataTable; + +import java.util.List; + +public class MetaDataForm extends FormLayout { + + ComboBox table = new ComboBox<>("Table"); + TextField columnName = new TextField("Column Name"); + TextField columnSyncName = new TextField("Column Sync Name"); + TextField columnModifier = new TextField("Column Modifier"); + IntegerField columnOrder = new IntegerField("Column Order"); + Checkbox isShown = new Checkbox("Is Shown"); + + Button save = new com.vaadin.flow.component.button.Button("Save"); + Button delete = new com.vaadin.flow.component.button.Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(MetaDataColumn.class); + + public MetaDataForm(List tables) { + addClassName("metaData-form"); + binder.bindInstanceFields(this); + + table.setItems(tables); + table.setItemLabelGenerator(MetaDataTable::getTableName); + add(table, columnName, columnSyncName, columnModifier, columnOrder, isShown, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new MetaDataForm.DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new MetaDataForm.CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new MetaDataForm.SaveEvent(this, binder.getBean())); + } + } + + public void setMetaDataColumn(MetaDataColumn metaDataColumn) { + binder.setBean(metaDataColumn); + } + + public abstract static class MetaDataFormEvent extends ComponentEvent { + private MetaDataColumn metaDataColumn; + + protected MetaDataFormEvent(MetaDataForm source, MetaDataColumn metaDataColumn) { + super(source, false); + this.metaDataColumn = metaDataColumn; + } + + public MetaDataColumn getMetaDataColumn() { + return metaDataColumn; + } + } + + public static class SaveEvent extends MetaDataForm.MetaDataFormEvent { + SaveEvent(MetaDataForm source, MetaDataColumn metaDataColumn) { + super(source, metaDataColumn); + } + } + + public static class DeleteEvent extends MetaDataForm.MetaDataFormEvent { + DeleteEvent(MetaDataForm source, MetaDataColumn metaDataColumn) { + super(source, metaDataColumn); + } + } + + public static class CloseEvent extends MetaDataForm.MetaDataFormEvent { + CloseEvent(MetaDataForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(MetaDataForm.DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(MetaDataForm.SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(MetaDataForm.CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java new file mode 100644 index 0000000..661d4a3 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java @@ -0,0 +1,112 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import de.thpeetz.kontor.admin.AdminConstants; +import de.thpeetz.kontor.admin.data.MetaDataColumn; +import de.thpeetz.kontor.admin.services.MetaDataService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Scope; + +@Slf4j +@SpringComponent +@Scope("prototype") +@RolesAllowed("ROLE_ADMIN") +@Route(value = AdminConstants.METADATA_ROUTE, layout = MainLayout.class) +@PageTitle("Meta Data | Admin | Kontor") +public class MetaDataView extends VerticalLayout { + + Grid grid = new Grid<>(MetaDataColumn.class); + MetaDataForm form; + MetaDataService service; + + public MetaDataView(MetaDataService service) { + this.service = service; + addClassName("metadata-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("metadata-grid"); + grid.setSizeFull(); + grid.setColumns("table.tableName", "columnName", "columnSyncName", "columnModifier", "columnOrder", "isShown"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editMetaData(event.getValue())); + } + + private void configureForm() { + form = new MetaDataForm(service.findAllTables()); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveMetaData); + form.addDeleteListener(this::deleteMetaData); + form.addCloseListener(e -> closeEditor()); + } + + private void saveMetaData(MetaDataForm.SaveEvent event) { + MetaDataColumn metaDataColumn = event.getMetaDataColumn(); + service.saveMetaDataColumn(metaDataColumn); + updateList(); + closeEditor(); + } + + private void deleteMetaData(MetaDataForm.DeleteEvent event) { + service.deleteMetaDataColumn(event.getMetaDataColumn()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + Button addAuthorizationMaxtrixButton = new Button("Add Meta Data", click -> addMetaDataColumn()); + HorizontalLayout toolbar = new HorizontalLayout(addAuthorizationMaxtrixButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editMetaData(MetaDataColumn metaDataColumn) { + if (metaDataColumn == null) { + closeEditor(); + } else { + form.setMetaDataColumn(metaDataColumn); + form.setVisible(true); + addClassName("editing"); + } + } + + public void closeEditor() { + form.setMetaDataColumn(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addMetaDataColumn() { + grid.asSingleSelect().clear(); + editMetaData(new MetaDataColumn()); + } + + private void updateList() { + grid.setItems(service.findAllMetaDataColumns()); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataForm.java new file mode 100644 index 0000000..b2c5f8c --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataForm.java @@ -0,0 +1,100 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.admin.data.ModuleData; + +public class ModuleDataForm extends FormLayout { + TextField moduleName = new TextField("Module Name"); + Checkbox importData = new Checkbox("Import Data"); + + Button save = new com.vaadin.flow.component.button.Button("Save"); + Button delete = new com.vaadin.flow.component.button.Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(ModuleData.class); + + public ModuleDataForm() { + addClassName("moduleData-form"); + binder.bindInstanceFields(this); + add(moduleName, importData, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new ModuleDataForm.DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new ModuleDataForm.CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new ModuleDataForm.SaveEvent(this, binder.getBean())); + } + } + + public void setModuleData(ModuleData moduleData) { + binder.setBean(moduleData); + } + + public abstract static class ModuleDataFormEvent extends ComponentEvent { + private ModuleData moduleData; + + protected ModuleDataFormEvent(ModuleDataForm source, ModuleData moduleData) { + super(source, false); + this.moduleData = moduleData; + } + + public ModuleData getModuleData() { + return moduleData; + } + } + + public static class SaveEvent extends ModuleDataForm.ModuleDataFormEvent { + SaveEvent(ModuleDataForm source, ModuleData moduleData) { + super(source, moduleData); + } + } + + public static class DeleteEvent extends ModuleDataForm.ModuleDataFormEvent { + DeleteEvent(ModuleDataForm source, ModuleData moduleData) { + super(source, moduleData); + } + } + + public static class CloseEvent extends ModuleDataForm.ModuleDataFormEvent { + CloseEvent(ModuleDataForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(ModuleDataForm.DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(ModuleDataForm.SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(ModuleDataForm.CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataView.java new file mode 100644 index 0000000..bc335f3 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/ModuleDataView.java @@ -0,0 +1,118 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import de.thpeetz.kontor.admin.data.ModuleData; +import de.thpeetz.kontor.admin.services.ModuleService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Scope; + +@Slf4j +@SpringComponent +@Scope("prototype") +@RolesAllowed("ROLE_ADMIN") +@Route(value = "admin/module", layout = MainLayout.class) +@PageTitle("Module Data | Admin | Kontor") +public class ModuleDataView extends VerticalLayout { + + Grid grid = new Grid<>(ModuleData.class); + TextField filterText = new TextField(); + ModuleDataForm form; + ModuleService service; + + public ModuleDataView(ModuleService service) { + this.service = service; + addClassName("moduleData-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("moduleData-grid"); + grid.setSizeFull(); + grid.setColumns("moduleName", "importData"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editModuleData(event.getValue())); + } + + private void configureForm() { + form = new ModuleDataForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveModuleData); + form.addDeleteListener(this::deleteModuleData); + form.addCloseListener(e -> closeEditor()); + } + + private void saveModuleData(ModuleDataForm.SaveEvent event) { + ModuleData moduleData = event.getModuleData(); + service.saveModuleData(moduleData); + updateList(); + closeEditor(); + } + + private void deleteModuleData(ModuleDataForm.DeleteEvent event) { + service.deleteModuleData(event.getModuleData()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by module name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + Button addModuleDataButton = new Button("Add module", click -> addModuleData()); + HorizontalLayout toolbar = new HorizontalLayout(filterText, addModuleDataButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editModuleData(ModuleData moduleData) { + if (moduleData == null) { + closeEditor(); + } else { + form.setModuleData(moduleData); + form.setVisible(true); + addClassName("editing"); + } + } + + public void closeEditor() { + form.setModuleData(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addModuleData() { + grid.asSingleSelect().clear(); + editModuleData(new ModuleData()); + } + + private void updateList() { + grid.setItems(service.findAll(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleForm.java new file mode 100644 index 0000000..671d0f5 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleForm.java @@ -0,0 +1,102 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.admin.data.Role; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class RoleForm extends FormLayout { + + TextField name = new TextField("Role name"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Role.class); + + public RoleForm() { + addClassName("role-form"); + binder.bindInstanceFields(this); + add(name, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setRole(Role role) { + binder.setBean(role); + } + + public abstract static class RoleFormEvent extends ComponentEvent { + private Role role; + + protected RoleFormEvent(RoleForm source, Role role) { + super(source, false); + this.role = role; + } + + public Role getRole() { + return role; + } + } + + public static class SaveEvent extends RoleFormEvent { + SaveEvent(RoleForm source, Role role) { + super(source, role); + } + } + + public static class DeleteEvent extends RoleFormEvent { + DeleteEvent(RoleForm source, Role role) { + super(source, role); + } + } + + public static class CloseEvent extends RoleFormEvent { + CloseEvent(RoleForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleView.java new file mode 100644 index 0000000..1f13c9a --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/RoleView.java @@ -0,0 +1,118 @@ +package de.thpeetz.kontor.admin.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.admin.AdminConstants; +import de.thpeetz.kontor.admin.data.Role; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringComponent +@Scope("prototype") +@RolesAllowed("ROLE_ADMIN") +@Route(value = AdminConstants.ROLE_ROUTE, layout = MainLayout.class) +@PageTitle("Rollen | Admin | Kontor") +public class RoleView extends VerticalLayout { + Grid grid = new Grid<>(Role.class); + TextField filterText = new TextField(); + RoleForm form; + AdminService service; + + public RoleView(AdminService service) { + this.service = service; + addClassName("user-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("user-grid"); + grid.setSizeFull(); + grid.setColumns("name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editRole(event.getValue())); + } + + private void configureForm() { + form = new RoleForm(); + form.setWidth("25em"); + form.addSaveListener(this::saveRole); + form.addDeleteListener(this::deleteRole); + form.addCloseListener(e -> closeEditor()); + } + + private void saveRole(RoleForm.SaveEvent event) { + service.saveRole(event.getRole()); + updateList(); + closeEditor(); + } + + private void deleteRole(RoleForm.DeleteEvent event) { + service.deleteRole(event.getRole()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by user name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + Button addUserButton = new Button("Add user", click -> addUser()); + HorizontalLayout toolbar = new HorizontalLayout(filterText, addUserButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editRole(Role role) { + if (role == null) { + closeEditor(); + } else { + form.setRole(role); + form.setVisible(true); + addClassName("editing"); + } + } + + public void closeEditor() { + form.setRole(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addUser() { + grid.asSingleSelect().clear(); + editRole(new Role()); + } + + private void updateList() { + grid.setItems(service.findAllRoles(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserForm.java new file mode 100644 index 0000000..21298f3 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserForm.java @@ -0,0 +1,143 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.checkbox.CheckboxGroup; +import com.vaadin.flow.component.checkbox.CheckboxGroupVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.EmailField; +import com.vaadin.flow.component.textfield.PasswordField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.admin.data.Role; +import de.thpeetz.kontor.admin.data.User; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public class UserForm extends FormLayout { + + TextField userName = new TextField("User name"); + PasswordField password = new PasswordField("Password"); + EmailField email = new EmailField("Email"); + TextField firstName = new TextField("First name"); + TextField lastName = new TextField("Last name"); + Checkbox enabled = new Checkbox("Enabled"); + String originalPassword; + + CheckboxGroup permissions = new CheckboxGroup<>("Permissions"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(User.class); + + public UserForm() { + addClassName("user-form"); + binder.bindInstanceFields(this); + add(userName, password, email, firstName, lastName, enabled, configurePermissionsGroup(), createButtonsLayout()); + } + + private CheckboxGroup configurePermissionsGroup() { + permissions.addThemeVariants(CheckboxGroupVariant.LUMO_VERTICAL); + permissions.setItemLabelGenerator(Role::getName); + permissions.addValueChangeListener(event -> { + log.debug("permissions changed: {}", event); + }); + return permissions; + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setUser(User user) { + binder.setBean(user); + //log.debug("UserForm.setUser: {}", user); + if (user != null) { + this.originalPassword = user.getPassword(); + } else { + this.originalPassword = null; + } + } + + public void setRoles(List roles, User user) { + permissions.setItems(roles); + user.getMatrix().stream().forEach(authorizationMatrix -> { + permissions.select(authorizationMatrix.getRole()); + }); + } + + public boolean hasPasswordChanged(User user) { + return !originalPassword.equals(user.getPassword()); + } + + public abstract static class UserFormEvent extends ComponentEvent { + private User user; + + protected UserFormEvent(UserForm source, User user) { + super(source, false); + this.user = user; + } + + public User getUser() { + return user; + } + } + + public static class SaveEvent extends UserFormEvent { + SaveEvent(UserForm source, User user) { + super(source, user); + } + } + + public static class DeleteEvent extends UserFormEvent { + DeleteEvent(UserForm source, User user) { + super(source, user); + } + } + + public static class CloseEvent extends UserFormEvent { + CloseEvent(UserForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserProfileView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserProfileView.java new file mode 100644 index 0000000..b2a9ef8 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserProfileView.java @@ -0,0 +1,73 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import de.thpeetz.kontor.admin.data.User; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.security.SecurityService; +import jakarta.annotation.security.PermitAll; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Route(value="user/profile", layout = MainLayout.class) +@PermitAll +@PageTitle("Profile | User | Kontor") +public class UserProfileView extends VerticalLayout { + + private SecurityService securityService; + + private AdminService adminService; + + TextField firstName = new TextField("First name"); + TextField lastName = new TextField("Last name"); + + Button save = new Button("Save"); + Button close = new Button("Cancel"); + Binder binder = new BeanValidationBinder<>(User.class); + + public UserProfileView(AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + binder.bindInstanceFields(this); + + add(firstName, lastName, createButtonsLayout()); + securityService.getAuthenticatedUser().ifPresent(user -> { + log.info("UserProfileView: {}", user.getUsername()); + binder.setBean(adminService.getUser(user.getUsername())); + }); + + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + close.addClickListener(event -> closeView()); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + log.info("update user profile"); + } + } + + private void closeView() { + log.info("close user profile view"); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserView.java new file mode 100644 index 0000000..9f981ec --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/UserView.java @@ -0,0 +1,135 @@ +package de.thpeetz.kontor.admin.views; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import de.thpeetz.kontor.admin.data.Role; +import de.thpeetz.kontor.admin.data.User; +import de.thpeetz.kontor.admin.services.KontorUserDetailsService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Scope; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@SpringComponent +@Scope("prototype") +@RolesAllowed("ROLE_ADMIN") +@Route(value = "admin/user", layout = MainLayout.class) +@PageTitle("User | Admin | Kontor") +public class UserView extends VerticalLayout { + + Grid grid = new Grid<>(User.class); + TextField filterText = new TextField(); + UserForm form; + KontorUserDetailsService service; + + @Autowired + PasswordEncoder passwordEncoder; + + public UserView(KontorUserDetailsService service) { + this.service = service; + addClassName("user-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("user-grid"); + grid.setSizeFull(); + grid.setColumns("userName", "email", "firstName", "lastName", "enabled"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editUser(event.getValue())); + } + + private void configureForm() { + form = new UserForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveUser); + form.addDeleteListener(this::deleteUser); + form.addCloseListener(e -> closeEditor()); + } + + private void saveUser(UserForm.SaveEvent event) { + User user = event.getUser(); + log.debug("UserView.saveUser: {}", user); + List permissions = form.permissions.getSelectedItems().stream().collect(Collectors.toList()); + log.info("selected permissions: {}", permissions); + if (form.hasPasswordChanged(user)) { + user.setPassword(passwordEncoder.encode(user.getPassword())); + log.debug("password changed for user {}", user); + } + service.saveUser(user, permissions); + updateList(); + closeEditor(); + } + + private void deleteUser(UserForm.DeleteEvent event) { + service.deleteUser(event.getUser()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by user name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + Button addUserButton = new Button("Add user", click -> addUser()); + HorizontalLayout toolbar = new HorizontalLayout(filterText, addUserButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editUser(User user) { + if (user == null) { + closeEditor(); + } else { + form.setUser(user); + form.setRoles(service.findAllRoles(), user); + form.setVisible(true); + addClassName("editing"); + } + } + + public void closeEditor() { + form.setUser(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addUser() { + grid.asSingleSelect().clear(); + editUser(new User()); + } + + private void updateList() { + grid.setItems(service.findAllUsers(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/BookshelfConstants.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/BookshelfConstants.java new file mode 100644 index 0000000..58661a0 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/BookshelfConstants.java @@ -0,0 +1,53 @@ +package de.thpeetz.kontor.bookshelf; + +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.sidenav.SideNav; +import com.vaadin.flow.component.sidenav.SideNavItem; +import com.vaadin.flow.router.RouterLink; + +import de.thpeetz.kontor.bookshelf.views.ArticleView; +import de.thpeetz.kontor.bookshelf.views.AuthorView; +import de.thpeetz.kontor.bookshelf.views.BookView; +import de.thpeetz.kontor.bookshelf.views.BookshelfPublisherView; + +public class BookshelfConstants { + + public static final String AUTHOR = "Author"; + public static final String ARTICLE = "Article"; + public static final String BOOK = "Book"; + public static final String BOOKSHELF = "Bookshelf"; + public static final String PUBLISHER = "Publisher"; + public static final String PUBLISHER_ROUTE = "bookshelf/publisher"; + public static final String AUTHOR_ROUTE = "bookshelf/author"; + public static final String BOOK_ROUTE = "bookshelf/book"; + public static final String ARTICLE_ROUTE = "bookshelf/article"; + + public static RouterLink getPublisherLink() { + return new RouterLink(PUBLISHER, BookshelfPublisherView.class); + } + + public static RouterLink getAuthorLink() { + return new RouterLink(AUTHOR, AuthorView.class); + } + + public static RouterLink getBookLink() { + return new RouterLink(BOOK, BookView.class); + } + + public static RouterLink getArticleLink() { + return new RouterLink(ARTICLE, ArticleView.class); + } + + public static SideNavItem getBookshelfNavigation() { + SideNavItem bookshelf = new SideNavItem(BOOKSHELF, BookView.class, VaadinIcon.OPEN_BOOK.create()); + bookshelf.addItem(new SideNavItem(BOOK, BookView.class)); + bookshelf.addItem(new SideNavItem(PUBLISHER, BookshelfPublisherView.class)); + bookshelf.addItem(new SideNavItem(AUTHOR, AuthorView.class)); + bookshelf.addItem(new SideNavItem(ARTICLE, ArticleView.class)); + return bookshelf; + } + + private BookshelfConstants() { + // private constructor to hide the implicit public one + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/SetupModuleBookshelf.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/SetupModuleBookshelf.java new file mode 100644 index 0000000..abb0e9a --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/SetupModuleBookshelf.java @@ -0,0 +1,82 @@ +package de.thpeetz.kontor.bookshelf; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +import de.thpeetz.kontor.admin.services.ModuleService; +import de.thpeetz.kontor.bookshelf.data.ArticleAuthorRepository; +import de.thpeetz.kontor.bookshelf.data.Author; +import de.thpeetz.kontor.bookshelf.data.AuthorRepository; +import de.thpeetz.kontor.bookshelf.data.BookAuthorRepository; +import de.thpeetz.kontor.bookshelf.data.BookRepository; +import de.thpeetz.kontor.bookshelf.data.BookshelfPublisher; +import de.thpeetz.kontor.bookshelf.data.BookshelfPublisherRepository; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SetupModuleBookshelf implements ApplicationListener { + + boolean alreadySetup = false; + + @Autowired + private BookshelfPublisherRepository publisherRepository; + + @Autowired + private AuthorRepository authorRepository; + + @Autowired + private ArticleAuthorRepository articleAuthorRepository; + + @Autowired + private BookRepository bookRepository; + + @Autowired + private BookAuthorRepository bookAuthorRepository; + + @Autowired + private ModuleService moduleService; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (alreadySetup) { + log.info("SetupModuleBookshelf already executed, skipping"); + return; + } + if (!moduleService.importData(BookshelfConstants.BOOKSHELF)) { + log.info("Module Bookshelf should not setup data"); + return; + } + + log.info("Set up Bookshelf data"); + Author douglasadams = createAuthorIfNotFound("Douglas", "Adams"); + moduleService.setDataImported(BookshelfConstants.BOOKSHELF); + } + + private Author createAuthorIfNotFound(String firstName, String lastName) { + log.info("createAuthorIfNotFound {} {}", firstName, lastName); + Author author = authorRepository.findByFirstNameAndLastName(firstName, lastName); + if (author == null) { + log.info("Author {} {} not found, will create it", firstName, lastName); + author = new Author(); + author.setFirstName(firstName); + author.setLastName(lastName); + authorRepository.save(author); + } + return author; + } + + private BookshelfPublisher createPublisherIfNotFound(String publisherName) { + log.info("createPublisherIfNotFound {}", publisherName); + BookshelfPublisher publisher = publisherRepository.findByName(publisherName); + if (publisher == null) { + log.info("Publisher {} not found, will create it", publisherName); + publisher = new BookshelfPublisher(); + publisher.setName(publisherName); + publisherRepository.save(publisher); + } + return publisher; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Article.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Article.java new file mode 100644 index 0000000..3ee6a16 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Article.java @@ -0,0 +1,27 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.LinkedList; +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Entity +@Table(indexes = @Index(columnList = "title"), uniqueConstraints = @UniqueConstraint(columnNames = {"title"})) +public class Article extends AbstractEntity { + + @NotEmpty + private String title; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "article") + @Nullable + private List authors = new LinkedList<>(); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthor.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthor.java new file mode 100644 index 0000000..c2b3851 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthor.java @@ -0,0 +1,29 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(indexes = @Index(columnList = "author_id, article_id"), uniqueConstraints = @UniqueConstraint(columnNames = {"author_id", "article_id"})) +public class ArticleAuthor extends AbstractEntity { + + @ManyToOne + @JoinColumn(name = "author_id") + @NotNull + private Author author; + + @ManyToOne + @JoinColumn(name = "article_id") + @NotNull + private Article article; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepository.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepository.java new file mode 100644 index 0000000..d7933de --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepository.java @@ -0,0 +1,12 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleAuthorRepository extends JpaRepository { + + List findByAuthor(Author author); + + List findByArticle(Article article); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleRepository.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleRepository.java new file mode 100644 index 0000000..c6b2602 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/ArticleRepository.java @@ -0,0 +1,16 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ArticleRepository extends JpaRepository { + + @Query("select a from Article a " + + "where lower(a.title) like lower(concat('%', :searchTerm, '%'))") + List

search(@Param("searchTerm") String searchTerm); + + List
findByTitle(String title); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Author.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Author.java new file mode 100644 index 0000000..42d83e0 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Author.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.LinkedList; +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Entity +@Table(indexes = { + @Index(columnList = "firstName, lastName"), + @Index(columnList = "lastName, firstName") +}, uniqueConstraints = { + @UniqueConstraint(columnNames = { "firstName", "lastName" }) +}) +public class Author extends AbstractEntity { + + @NotEmpty + private String firstName; + + @NotEmpty + private String lastName; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "author") + @Nullable + private List bookAuthors = new LinkedList<>(); + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "author") + @Nullable + private List articleAuthors = new LinkedList<>(); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/AuthorRepository.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/AuthorRepository.java new file mode 100644 index 0000000..3e87334 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/AuthorRepository.java @@ -0,0 +1,18 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AuthorRepository extends JpaRepository { + + @Query("select a from Author a " + + "where lower(a.firstName) like lower(concat('%', :searchTerm, '%')) " + + "or lower(a.lastName) like lower(concat('%', :searchTerm, '%'))") + List search(@Param("searchTerm") String searchTerm); + + Author findByFirstNameAndLastName(String firstName, String lastName); + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Book.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Book.java new file mode 100644 index 0000000..e0f6782 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/Book.java @@ -0,0 +1,42 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.LinkedList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Entity +@Table(indexes = @Index(columnList = "title, isbn"), uniqueConstraints = @UniqueConstraint(columnNames = {"isbn"})) +public class Book extends AbstractEntity { + + @NotEmpty + private String title; + + @NotEmpty + private String isbn; + + @Nullable + private int year; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "book") + @Nullable + private List authors = new LinkedList<>(); + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "publisher_id") + @NotNull + @JsonIgnoreProperties({ "books" }) + private BookshelfPublisher publisher; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthor.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthor.java new file mode 100644 index 0000000..86a0e10 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthor.java @@ -0,0 +1,29 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(indexes = @Index(columnList = "author_id, book_id"), uniqueConstraints = @UniqueConstraint(columnNames = {"author_id", "book_id"})) +public class BookAuthor extends AbstractEntity { + + @ManyToOne + @JoinColumn(name = "author_id") + @NotNull + private Author author; + + @ManyToOne + @JoinColumn(name = "book_id") + @NotNull + private Book book; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepository.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepository.java new file mode 100644 index 0000000..a0cf650 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepository.java @@ -0,0 +1,12 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookAuthorRepository extends JpaRepository { + + List findByAuthor(Author author); + + List findByBook(Book book); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookRepository.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookRepository.java new file mode 100644 index 0000000..e6ee013 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookRepository.java @@ -0,0 +1,21 @@ +package de.thpeetz.kontor.bookshelf.data; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface BookRepository extends JpaRepository { + + @Query("select b from Book b " + + "where lower(b.title) like lower(concat('%', :searchTerm, '%')) " + + "or lower(b.isbn) like lower(concat('%', :searchTerm, '%'))") + List search(@Param("searchTerm") String searchTerm); + + List findByTitle(String name); + + List findByTitleIgnoreCase(String name); + + List findByIsbn(String isbn); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisher.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisher.java new file mode 100644 index 0000000..da97d7a --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisher.java @@ -0,0 +1,32 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.LinkedList; +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@Entity +@Table(indexes = @Index(columnList = "name"), uniqueConstraints = @UniqueConstraint(columnNames = { "name" })) +public class BookshelfPublisher extends AbstractEntity { + + @NotEmpty + private String name; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "publisher", orphanRemoval = true) + @Nullable + List books = new LinkedList<>(); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepository.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepository.java new file mode 100644 index 0000000..b50ba84 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepository.java @@ -0,0 +1,18 @@ +package de.thpeetz.kontor.bookshelf.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface BookshelfPublisherRepository extends JpaRepository { + + @Query("select p from BookshelfPublisher p " + + "where lower(p.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + BookshelfPublisher findByName(String name); + + List findByNameIgnoreCase(String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/services/BookshelfService.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/services/BookshelfService.java new file mode 100644 index 0000000..6d92a2c --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/services/BookshelfService.java @@ -0,0 +1,118 @@ +package de.thpeetz.kontor.bookshelf.services; + +import java.util.List; + +import de.thpeetz.kontor.bookshelf.data.*; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class BookshelfService { + + private final AuthorRepository authorRepository; + private final ArticleAuthorRepository articleAuthorRepository; + private final ArticleRepository articleRepository; + private final BookRepository bookRepository; + private final BookAuthorRepository bookAuthorRepository; + private final BookshelfPublisherRepository publisherRepository; + + public BookshelfService(AuthorRepository authorRepository, ArticleAuthorRepository articleAuthorRepository, + ArticleRepository articleRepository, BookRepository bookRepository, + BookAuthorRepository bookAuthorRepository, BookshelfPublisherRepository publisherRepository) { + this.authorRepository = authorRepository; + this.articleAuthorRepository = articleAuthorRepository; + this.articleRepository = articleRepository; + this.bookRepository = bookRepository; + this.bookAuthorRepository = bookAuthorRepository; + this.publisherRepository = publisherRepository; + } + + public List findAllPublishers(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return publisherRepository.findAll(); + } else { + return publisherRepository.search(stringFilter); + } + } + + public BookshelfPublisher findPublisherByName(String publisherName) { + return publisherRepository.findByName(publisherName); + } + + public void deletePublisher(BookshelfPublisher publisher) { + publisherRepository.delete(publisher); + } + + public void savePublisher(BookshelfPublisher publisher) { + if (publisher == null) { + log.warn("Publisher is null. Are you sure you have connected your form to the application?"); + return; + } + publisherRepository.save(publisher); + } + + public List findAllAuthors(String filter) { + if (filter == null || filter.isEmpty()) { + return authorRepository.findAll(); + } else { + return authorRepository.search(filter); + } + } + + public void saveAuthor(Author author) { + if (author == null) { + log.warn("Author is null. Are you sure you have connected your form to the application?"); + return; + } + authorRepository.save(author); + } + + public void deleteAuthor(Author author) { + authorRepository.delete(author); + } + + public List findAllBooks(String filter) { + if (filter == null || filter.isEmpty()) { + return bookRepository.findAll(); + } else { + return bookRepository.search(filter); + } + } + + public void saveBook(Book book) { + if (book == null) { + log.warn("Book is null. Are you sure you have connected your form to the application?"); + return; + } + bookRepository.save(book); + } + + public void deleteBook(Book book) { + BookshelfPublisher publisher = book.getPublisher(); + publisher.getBooks().remove(book); + publisherRepository.save(publisher); + bookRepository.delete(book); + } + + public List
findAllArticles(String filter) { + if (filter == null || filter.isEmpty()) { + return articleRepository.findAll(); + } else { + return articleRepository.search(filter); + } + } + + public void saveArticle(Article article) { + if (article == null) { + log.warn("Article is null, Are you sure you have connected your form to the application?"); + return; + } + articleRepository.save(article); + } + + public void deleteArticle(Article article) { + articleRepository.delete(article); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleForm.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleForm.java new file mode 100644 index 0000000..7fb7958 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleForm.java @@ -0,0 +1,106 @@ +package de.thpeetz.kontor.bookshelf.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.bookshelf.data.Article; +import de.thpeetz.kontor.bookshelf.data.Author; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public class ArticleForm extends FormLayout { + + TextField title = new TextField("Title"); + Grid author = new Grid<>(Author.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder
binder = new BeanValidationBinder<>(Article.class); + + public ArticleForm() { + addClassName("article-form"); + binder.bindInstanceFields(this); + + add(title, author, createButtonsLayout()); + } + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new ArticleForm.DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new ArticleForm.CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new ArticleForm.SaveEvent(this, binder.getBean())); + } + } + + public void setArticle(Article article) { + binder.setBean(article); + } + + public abstract static class ArticleFormEvent extends ComponentEvent { + private Article article; + + protected ArticleFormEvent(ArticleForm source, Article article) { + super(source, false); + this.article = article; + } + + public Article getArticle() { + return article; + } + } + + public static class SaveEvent extends ArticleForm.ArticleFormEvent { + SaveEvent(ArticleForm source, Article article) { + super(source, article); + } + } + + public static class DeleteEvent extends ArticleForm.ArticleFormEvent { + DeleteEvent(ArticleForm source, Article article) { + super(source, article); + } + } + + public static class CloseEvent extends ArticleForm.ArticleFormEvent { + CloseEvent(ArticleForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(ArticleForm.DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(ArticleForm.SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(ArticleForm.CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleView.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleView.java new file mode 100644 index 0000000..334f577 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/ArticleView.java @@ -0,0 +1,126 @@ +package de.thpeetz.kontor.bookshelf.views; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import de.thpeetz.kontor.bookshelf.BookshelfConstants; +import de.thpeetz.kontor.bookshelf.data.Article; +import de.thpeetz.kontor.bookshelf.data.Book; +import de.thpeetz.kontor.bookshelf.data.BookAuthor; +import de.thpeetz.kontor.bookshelf.services.BookshelfService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; +import lombok.Getter; +import org.springframework.context.annotation.Scope; + +import java.util.stream.Collectors; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = BookshelfConstants.ARTICLE_ROUTE, layout = MainLayout.class) +@PageTitle("Article | Bookshelf | Kontor") +public class ArticleView extends VerticalLayout { + + @Getter + Grid
grid = new Grid<>(Article.class); + TextField filterText = new TextField(); + @Getter + ArticleForm form; + BookshelfService service; + + public ArticleView(BookshelfService service) { + this.service = service; + addClassName("article-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("article-grid"); + grid.setSizeFull(); + grid.setColumns("title"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editArticle(event.getValue())); + } + + private void configureForm() { + form = new ArticleForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveArticle); + form.addDeleteListener(this::deleteArticle); + form.addCloseListener(e -> closeEditor()); + } + + private void saveArticle(ArticleForm.SaveEvent event) { + service.saveArticle(event.getArticle()); + updateList(); + closeEditor(); + } + + private void deleteArticle(ArticleForm.DeleteEvent event) { + service.deleteArticle(event.getArticle()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by title or isbn..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addArticleButton = new Button("Add article"); + addArticleButton.addClickListener(click -> addArticle()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addArticleButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editArticle(Article article) { + if (article == null) { + closeEditor(); + } else { + form.setArticle(article); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setArticle(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addArticle() { + grid.asSingleSelect().clear(); + editArticle(new Article()); + } + + public void updateList() { + grid.setItems(service.findAllArticles(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorForm.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorForm.java new file mode 100644 index 0000000..34d0f5d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorForm.java @@ -0,0 +1,117 @@ +package de.thpeetz.kontor.bookshelf.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.listbox.ListBox; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.bookshelf.data.Author; +import de.thpeetz.kontor.bookshelf.data.Book; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public class AuthorForm extends FormLayout { + + TextField firstName = new TextField("First Name"); + TextField lastName = new TextField("Last Name"); + ListBox books = new ListBox<>(); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Author.class); + + public AuthorForm() { + addClassName("author-form"); + binder.bindInstanceFields(this); + + books.setHeight("100px"); + //books.setColumns("title", "publisher.name"); + //books.getColumns().forEach(col -> col.setAutoWidth(true)); + add(firstName, lastName, books, createButtonsLayout()); + } + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new AuthorForm.DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new AuthorForm.CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new AuthorForm.SaveEvent(this, binder.getBean())); + } + } + + public void setAuthor(Author author) { + binder.setBean(author); + } + + public void setBooks(List books) { + log.info("setting Books: ", books); + this.books.setItems(books); + } + + public abstract static class AuthorFormEvent extends ComponentEvent { + private Author author; + + protected AuthorFormEvent(AuthorForm source, Author author) { + super(source, false); + this.author = author; + } + + public Author getAuthor() { + return author; + } + } + + public static class SaveEvent extends AuthorForm.AuthorFormEvent { + SaveEvent(AuthorForm source, Author author) { + super(source, author); + } + } + + public static class DeleteEvent extends AuthorForm.AuthorFormEvent { + DeleteEvent(AuthorForm source, Author author) { + super(source, author); + } + } + + public static class CloseEvent extends AuthorForm.AuthorFormEvent { + CloseEvent(AuthorForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(AuthorForm.DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(AuthorForm.SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(AuthorForm.CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorView.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorView.java new file mode 100644 index 0000000..17897b0 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/AuthorView.java @@ -0,0 +1,132 @@ +package de.thpeetz.kontor.bookshelf.views; + +import java.util.stream.Collectors; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.bookshelf.BookshelfConstants; +import de.thpeetz.kontor.bookshelf.data.Author; +import de.thpeetz.kontor.bookshelf.data.BookAuthor; +import de.thpeetz.kontor.bookshelf.services.BookshelfService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = BookshelfConstants.AUTHOR_ROUTE, layout = MainLayout.class) +@PageTitle("Author | Bookshelf | Kontor") +public class AuthorView extends VerticalLayout { + + Grid grid = new Grid<>(Author.class); + TextField filterText = new TextField(); + AuthorForm form; + BookshelfService service; + + public AuthorView(BookshelfService service) { + this.service = service; + addClassName("author-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + public AuthorForm getForm() { + return form; + } + + private void configureGrid() { + grid.addClassName("author-grid"); + grid.setSizeFull(); + grid.setColumns("firstName", "lastName"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editAuthor(event.getValue())); + } + + private void configureForm() { + form = new AuthorForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveAuthor); + form.addDeleteListener(this::deleteAuthor); + form.addCloseListener(e -> closeEditor()); + } + private void saveAuthor(AuthorForm.SaveEvent event) { + service.saveAuthor(event.getAuthor()); + updateList(); + closeEditor(); + } + + private void deleteAuthor(AuthorForm.DeleteEvent event) { + service.deleteAuthor(event.getAuthor()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addAuthorButton = new Button("Add author"); + addAuthorButton.addClickListener(click -> addAuthor()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addAuthorButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editAuthor(Author author) { + if (author == null) { + closeEditor(); + } else { + form.setAuthor(author); + form.setBooks(author.getBookAuthors().stream().map(BookAuthor::getBook).collect(Collectors.toList())); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setAuthor(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addAuthor() { + grid.asSingleSelect().clear(); + editAuthor(new Author()); + } + + public void updateList() { + grid.setItems(service.findAllAuthors(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookForm.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookForm.java new file mode 100644 index 0000000..61da274 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookForm.java @@ -0,0 +1,111 @@ +package de.thpeetz.kontor.bookshelf.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.bookshelf.data.Book; +import de.thpeetz.kontor.bookshelf.data.BookshelfPublisher; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public class BookForm extends FormLayout { + + TextField title = new TextField("Title"); + TextField isbn = new TextField("ISBN"); + IntegerField year = new IntegerField("Year"); + ComboBox publisher = new ComboBox<>("Publisher"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Book.class); + + public BookForm(List publishers) { + addClassName("book-form"); + binder.bindInstanceFields(this); + + publisher.setItems(publishers); + publisher.setItemLabelGenerator(BookshelfPublisher::getName); + add(title, isbn, year, publisher, createButtonsLayout()); + } + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new BookForm.DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new BookForm.CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new BookForm.SaveEvent(this, binder.getBean())); + } + } + + public void setBook(Book book) { + binder.setBean(book); + } + + public abstract static class BookFormEvent extends ComponentEvent { + private Book book; + + protected BookFormEvent(BookForm source, Book book) { + super(source, false); + this.book = book; + } + + public Book getBook() { + return book; + } + } + + public static class SaveEvent extends BookForm.BookFormEvent { + SaveEvent(BookForm source, Book book) { + super(source, book); + } + } + + public static class DeleteEvent extends BookForm.BookFormEvent { + DeleteEvent(BookForm source, Book book) { + super(source, book); + } + } + + public static class CloseEvent extends BookForm.BookFormEvent { + CloseEvent(BookForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(BookForm.DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(BookForm.SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(BookForm.CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookView.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookView.java new file mode 100644 index 0000000..9e04dc6 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookView.java @@ -0,0 +1,124 @@ +package de.thpeetz.kontor.bookshelf.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.bookshelf.BookshelfConstants; +import de.thpeetz.kontor.bookshelf.data.Book; +import de.thpeetz.kontor.bookshelf.services.BookshelfService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; +import lombok.Getter; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = BookshelfConstants.BOOK_ROUTE, layout = MainLayout.class) +@PageTitle("Book | Bookshelf | Kontor") +public class BookView extends VerticalLayout { + + @Getter + Grid grid = new Grid<>(Book.class); + TextField filterText = new TextField(); + @Getter + BookForm form; + BookshelfService service; + + public BookView(BookshelfService service) { + this.service = service; + addClassName("book-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("book-grid"); + grid.setSizeFull(); + grid.setColumns("title", "isbn", "publisher.name", "year"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editBook(event.getValue())); + } + + private void configureForm() { + form = new BookForm(service.findAllPublishers(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveBook); + form.addDeleteListener(this::deleteBook); + form.addCloseListener(e -> closeEditor()); + } + + private void saveBook(BookForm.SaveEvent event) { + service.saveBook(event.getBook()); + updateList(); + closeEditor(); + } + + private void deleteBook(BookForm.DeleteEvent event) { + service.deleteBook(event.getBook()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by title or isbn..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addBookButton = new Button("Add book"); + addBookButton.addClickListener(click -> addBook()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addBookButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editBook(Book book) { + if (book == null) { + closeEditor(); + } else { + form.setBook(book); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setBook(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addBook() { + grid.asSingleSelect().clear(); + editBook(new Book()); + } + + public void updateList() { + grid.setItems(service.findAllBooks(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfLayout.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfLayout.java new file mode 100644 index 0000000..6105a47 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfLayout.java @@ -0,0 +1,34 @@ +package de.thpeetz.kontor.bookshelf.views; + +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.theme.lumo.LumoUtility; + +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.bookshelf.BookshelfConstants; +import de.thpeetz.kontor.common.views.KontorLayoutUtil; +import de.thpeetz.kontor.security.SecurityService; + +public class BookshelfLayout extends AppLayout { + + private final AdminService adminService; + + private final SecurityService securityService; + + public BookshelfLayout(AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + + KontorLayoutUtil layout = new KontorLayoutUtil(this, adminService, securityService); + layout.setSecondaryNavigation(getSecondaryNavigation()); + layout.createHeader(BookshelfConstants.BOOKSHELF); + } + + private HorizontalLayout getSecondaryNavigation() { + HorizontalLayout navigation = new HorizontalLayout(); + navigation.addClassNames(LumoUtility.JustifyContent.CENTER, LumoUtility.Gap.SMALL, LumoUtility.Height.MEDIUM); + navigation.add(BookshelfConstants.getBookLink(), BookshelfConstants.getArticleLink(), + BookshelfConstants.getPublisherLink(), BookshelfConstants.getAuthorLink()); + return navigation; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfPublisherView.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfPublisherView.java new file mode 100644 index 0000000..de5f9b5 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/BookshelfPublisherView.java @@ -0,0 +1,131 @@ +package de.thpeetz.kontor.bookshelf.views; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.common.views.SeparateMainLayout; +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.bookshelf.BookshelfConstants; +import de.thpeetz.kontor.bookshelf.data.BookshelfPublisher; +import de.thpeetz.kontor.bookshelf.services.BookshelfService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = BookshelfConstants.PUBLISHER_ROUTE, layout = MainLayout.class) +@PageTitle("Publisher | Bookshelf | Kontor") +public class BookshelfPublisherView extends VerticalLayout { + + Grid grid = new Grid<>(BookshelfPublisher.class); + TextField filterText = new TextField(); + PublisherForm form; + + BookshelfService service; + + public BookshelfPublisherView(BookshelfService service) { + this.service = service; + addClassName("publisher-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("publisher-grid"); + grid.setSizeFull(); + grid.setColumns("name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editPublisher(event.getValue())); + } + + private void configureForm() { + form = new PublisherForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::savePublisher); + form.addDeleteListener(this::deletePublisher); + form.addCloseListener(e -> closeEditor()); + } + + private void savePublisher(PublisherForm.SaveEvent event) { + service.savePublisher(event.getPublisher()); + updateList(); + closeEditor(); + } + + private void deletePublisher(PublisherForm.DeleteEvent event) { + service.deletePublisher(event.getPublisher()); + updateList(); + closeEditor(); + } + + public Grid getGrid() { + return grid; + } + + public PublisherForm getForm() { + return form; + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addPublisherButton = new Button("Add publisher"); + addPublisherButton.addClickListener(click -> addPublisher()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addPublisherButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editPublisher(BookshelfPublisher publisher) { + if (publisher == null) { + closeEditor(); + } else { + form.setPublisher(publisher); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setPublisher(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addPublisher() { + grid.asSingleSelect().clear(); + editPublisher(new BookshelfPublisher()); + } + + public void updateList() { + grid.setItems(service.findAllPublishers(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/PublisherForm.java b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/PublisherForm.java new file mode 100644 index 0000000..7b48122 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/bookshelf/views/PublisherForm.java @@ -0,0 +1,108 @@ +package de.thpeetz.kontor.bookshelf.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.bookshelf.data.BookshelfPublisher; + +public class PublisherForm extends FormLayout { + private TextField name = new TextField("Name"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder(BookshelfPublisher.class); + + public PublisherForm() { + addClassName("publisher-form"); + binder.bindInstanceFields(this); + + add(name, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public TextField getName() { + return name; + } + + public void setName(TextField name) { + this.name = name; + } + + public void setPublisher(BookshelfPublisher publisher) { + binder.setBean(publisher); + } + + public abstract static class PublisherFormEvent extends ComponentEvent { + private BookshelfPublisher publisher; + + protected PublisherFormEvent(PublisherForm source, BookshelfPublisher publisher) { + super(source, false); + this.publisher = publisher; + } + + public BookshelfPublisher getPublisher() { + return publisher; + } + } + + public static class SaveEvent extends PublisherFormEvent { + SaveEvent(PublisherForm source, BookshelfPublisher publisher) { + super(source, publisher); + } + } + + public static class DeleteEvent extends PublisherFormEvent { + DeleteEvent(PublisherForm source, BookshelfPublisher publisher) { + super(source, publisher); + } + } + + public static class CloseEvent extends PublisherFormEvent { + CloseEvent(PublisherForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/ComicConstants.java b/springboot/src/main/java/de/thpeetz/kontor/comics/ComicConstants.java new file mode 100644 index 0000000..c89f7b1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/ComicConstants.java @@ -0,0 +1,93 @@ +package de.thpeetz.kontor.comics; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.sidenav.SideNavItem; +import com.vaadin.flow.router.RouterLink; + +import de.thpeetz.kontor.comics.views.ArtistView; +import de.thpeetz.kontor.comics.views.ComicView; +import de.thpeetz.kontor.comics.views.ComicWorkView; +import de.thpeetz.kontor.comics.views.IssueView; +import de.thpeetz.kontor.comics.views.PublisherView; +import de.thpeetz.kontor.comics.views.StoryArcView; +import de.thpeetz.kontor.comics.views.TradePaperbackView; +import de.thpeetz.kontor.comics.views.VolumeView; +import de.thpeetz.kontor.comics.views.WorktypeView; + +/** + * The {@code ComicConstants} class contains constant values related to comics. + */ +public class ComicConstants { + + public static final String COMICS = "Comics"; + public static final String COMICS_ROUTE = "comics/comic"; + public static final String PUBLISHER = "Publisher"; + public static final String PUBLISHER_ROUTE = "comics/publisher"; + public static final String COMICWORK = "ComicWork"; + public static final String COMICWORK_ROUTE = "comics/comicwork"; + public static final String WORKTYPE = "Worktype"; + public static final String WORKTYPE_ROUTE = "comics/worktype"; + public static final String ARTIST = "Artist"; + public static final String ARTIST_ROUTE = "comics/artist"; + public static final String TPB = "TradePaperback"; + public static final String TPB_ROUTE = "comics/tradepaperback"; + public static final String STORYARC = "Story Arc"; + public static final String STORYARC_ROTE = "comics/storyarc"; + public static final String ISSUE = "Issue"; + public static final String ISSUE_ROUTE = "comics/issue"; + public static final String VOLUME = "Volume"; + public static final String VOLUME_ROUTE = "comics/volume"; + + public static RouterLink getArtistLink() { + return new RouterLink(ARTIST, ArtistView.class); + } + + public static RouterLink getComicLink() { + return new RouterLink(COMICS, ComicView.class); + } + + public static RouterLink getPublisherLink() { + return new RouterLink(PUBLISHER, PublisherView.class); + } + + public static RouterLink getComicWorkLink() { + return new RouterLink(COMICWORK, ComicWorkView.class); + } + + public static RouterLink getIssueLink() { + return new RouterLink(ISSUE, IssueView.class); + } + + public static RouterLink getTradePaperbackLink() { + return new RouterLink(TPB, TradePaperbackView.class); + } + + public static Component getStoryArcLink() { + return new RouterLink(STORYARC, StoryArcView.class); + } + + public static RouterLink getWorktypeLink() { + return new RouterLink(WORKTYPE, WorktypeView.class); + } + + public static RouterLink getVolumeLink() { + return new RouterLink(VOLUME, VolumeView.class); + } + + public static SideNavItem getComicsNavigation() { + SideNavItem comics = new SideNavItem(COMICS, COMICS_ROUTE, VaadinIcon.RECORDS.create()); + comics.addItem(new SideNavItem(ARTIST, ArtistView.class)); + comics.addItem(new SideNavItem(COMICS, ComicView.class)); + comics.addItem(new SideNavItem(PUBLISHER, PublisherView.class)); + comics.addItem(new SideNavItem(ISSUE, IssueView.class)); + comics.addItem(new SideNavItem(TPB, TradePaperbackView.class)); + comics.addItem(new SideNavItem(STORYARC, StoryArcView.class)); + comics.addItem(new SideNavItem(VOLUME, VolumeView.class)); + return comics; + } + + private ComicConstants() { + // private constructor to hide the implicit public one + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/SetupModuleComics.java b/springboot/src/main/java/de/thpeetz/kontor/comics/SetupModuleComics.java new file mode 100644 index 0000000..52674fd --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/SetupModuleComics.java @@ -0,0 +1,1198 @@ +package de.thpeetz.kontor.comics; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +import de.thpeetz.kontor.admin.services.ModuleService; +import de.thpeetz.kontor.comics.data.Artist; +import de.thpeetz.kontor.comics.data.ArtistRepository; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.ComicRepository; +import de.thpeetz.kontor.comics.data.ComicWork; +import de.thpeetz.kontor.comics.data.ComicWorkRepository; +import de.thpeetz.kontor.comics.data.Issue; +import de.thpeetz.kontor.comics.data.IssueRepository; +import de.thpeetz.kontor.comics.data.Publisher; +import de.thpeetz.kontor.comics.data.PublisherRepository; +import de.thpeetz.kontor.comics.data.StoryArc; +import de.thpeetz.kontor.comics.data.StoryArcRepository; +import de.thpeetz.kontor.comics.data.TradePaperback; +import de.thpeetz.kontor.comics.data.TradePaperbackRepository; +import de.thpeetz.kontor.comics.data.Volume; +import de.thpeetz.kontor.comics.data.VolumeRepository; +import de.thpeetz.kontor.comics.data.Worktype; +import de.thpeetz.kontor.comics.data.WorktypeRepository; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SetupModuleComics implements ApplicationListener { + + boolean alreadySetup = false; + + @Autowired + private PublisherRepository publisherRepository; + + @Autowired + private ArtistRepository artistRepository; + + @Autowired + private WorktypeRepository worktypeRepository; + + @Autowired + private ComicRepository comicRepository; + + @Autowired + private ComicWorkRepository comicWorkRepository; + + @Autowired + private StoryArcRepository storyArcRepository; + + @Autowired + private TradePaperbackRepository tradePaperbackRepository; + + @Autowired + private IssueRepository issueRepository; + + @Autowired + private VolumeRepository volumeRepository; + + @Autowired + private ModuleService moduleService; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (alreadySetup) { + log.info("SetupModuleComics already executed, skipping"); + return; + } + if (!moduleService.importData(ComicConstants.COMICS)) { + log.info("Module comics should not setup data"); + return; + } + log.info("Set up Comics data"); + Publisher marvel = createPublisherIfNotFound("Marvel"); + Publisher alias = createPublisherIfNotFound("Alias"); + Publisher crossgen = createPublisherIfNotFound("Crossgen"); + Publisher image = createPublisherIfNotFound("Image"); + Publisher ddp = createPublisherIfNotFound("Devils Due Publishing"); + Publisher aspen = createPublisherIfNotFound("Aspen"); + Publisher bongo = createPublisherIfNotFound("Bongo Comics"); + Publisher kandora = createPublisherIfNotFound("Kandora"); + Publisher dc = createPublisherIfNotFound("DC"); + Publisher marvelknights = createPublisherIfNotFound("Marvel Knights"); + Publisher wildstorm = createPublisherIfNotFound("WildStorm"); + Publisher cliffhanger = createPublisherIfNotFound("Cliffhanger"); + Publisher darkhorse = createPublisherIfNotFound("Dark Horse Comics"); + Publisher broadsword = createPublisherIfNotFound("Broadsword"); + Publisher dynamite = createPublisherIfNotFound("Dynamite Entertainment"); + Publisher redeagle = createPublisherIfNotFound("Red Eagle Entertainment"); + Publisher topcow = createPublisherIfNotFound("Top Cow Productions"); + Publisher pulpfiction = createPublisherIfNotFound("Pulp Fiction"); + Artist michaelturner = createArtistIfNotFound("Turner, Michael"); + createArtistIfNotFound("Marz, Ron"); + createArtistIfNotFound("Whedon, Joss"); + createArtistIfNotFound("Land, Greg"); + Artist brianbendis = createArtistIfNotFound("Bendis, Brian Michael"); + Worktype writer = createWorktypeIfNotFound("Writer"); + createWorktypeIfNotFound("Penciler"); + createWorktypeIfNotFound("Inker"); + Comic comic1602 = createComicIfNotFound(marvel, "1602", false, false); + createComicIfNotFound(alias, "10th Muse", false, false); + Comic abadazad = createComicIfNotFound(crossgen, "Abadazad", false, false); + Comic amazingfantasy = createComicIfNotFound(marvel, "Amazing Fantasy", false, false); + Comic amazingspiderman = createComicIfNotFound(marvel, "Amazing Spider-Man", false, false); + Comic arana = createComicIfNotFound(marvel, "Arana", false, false); + Comic aria = createComicIfNotFound(image, "Aria", false, false); + createComicIfNotFound(ddp, "Army of Darkness", false, false); + Comic aspenComic = createComicIfNotFound(aspen, "Aspen", false, false); + Comic astonishingxmen = createComicIfNotFound(marvel, "Astonishing X-Men", false, false); + Comic athena = createComicIfNotFound(image, "Athena Inc. The Beginning", false, false); + Comic athenamanhunter = createComicIfNotFound(image, "Athena Inc. The Manhunter Project", false, false); + Comic barbarossa = createComicIfNotFound(kandora, "Barbarossa & The Lost Corsairs", false, false); + Comic bartsimpson = createComicIfNotFound(bongo, "Bart Simpson", false, false); + Comic bartsimpsontree = createComicIfNotFound(bongo, "Bart Simpsons Treehouse of Horror", false, false); + Comic battlepope = createComicIfNotFound(image, "Battle Pope", false, false); + Comic birdsofprey = createComicIfNotFound(dc, "Birds of Prey", false, false); + Comic blackwidow = createComicIfNotFound(marvelknights, "Black Widow", false, false); + Comic blackwidow2 = createComicIfNotFound(marvelknights, "Black Widow 2", false, false); + createComicIfNotFound(image, "Bluntman and Chronic", false, false); + Comic brath = createComicIfNotFound(crossgen, "Brath", false, false); + Comic catwomanrome = createComicIfNotFound(dc, "Catwoman When In Rome", false, false); + Comic crimson = createComicIfNotFound(wildstorm, "Crimson", false, false); + Comic crossgencomic = createComicIfNotFound(crossgen, "Crossgen", false, false); + Comic dangergirl = createComicIfNotFound(cliffhanger, "Danger Girl", false, false); + Comic dangergirlbackinblack = createComicIfNotFound(wildstorm, "Danger Girl Back in Black", false, false); + Comic daringescapes = createComicIfNotFound(image, "Daring Escapes", false, true); + Comic darknesssuperman = createComicIfNotFound(image, "Darkness / Superman", false, false); + Comic darknesstombraider = createComicIfNotFound(image, "Darkness / Tomb Raider", false, false); + Comic darknessvampirella = createComicIfNotFound(image, "Darkness / Vampirella", false, false); + Comic darknessblacksails = createComicIfNotFound(image, "Darkness Black Sails", false, false); + Comic darknessvol2 = createComicIfNotFound(image, "Darkness Vol. 2", false, false); + Comic districtx = createComicIfNotFound(marvel, "District X", false, false); + Comic dragonlance = createComicIfNotFound(ddp, "Dragonlance: Chronicles", false, false); + Comic dreampolice = createComicIfNotFound(marvel, "Dream Police", false, false); + Comic elcazador = createComicIfNotFound(crossgen, "El Cazador", false, false); + Comic elcazadortom = createComicIfNotFound(crossgen, "El Cazador The Bloody Ballad of Blackjack Tom", false, + false); + Comic elsinore = createComicIfNotFound(alias, "Elsinore", false, false); + Comic emmafrost = createComicIfNotFound(marvel, "Emma Frost", false, false); + Comic excalibur = createComicIfNotFound(marvel, "Excalibur", false, false); + Comic fathom = createComicIfNotFound(aspen, "Fathom", false, false); + Comic fathombeginnings = createComicIfNotFound(aspen, "Fathom Beginnings", false, false); + Comic fathomcannon = createComicIfNotFound(aspen, "Fathom Cannon Hawke", false, false); + Comic fathomcannonprelude = createComicIfNotFound(aspen, "Fathom Cannon Hawke Prelude", false, false); + Comic fathomdawnofwar = createComicIfNotFound(aspen, "Fathom Dawn of War", false, false); + Comic fathomprelude = createComicIfNotFound(aspen, "Fathom Prelude", false, false); + Comic fathomswimsuit = createComicIfNotFound(aspen, "Fathom Swimsuit Special", false, false); + Comic fathomswimsuit2000 = createComicIfNotFound(aspen, "Fathom Swimsuit Special 2000", false, false); + Comic fathomvol2 = createComicIfNotFound(aspen, "Fathom Vol. 2", false, false); + Comic fathomkillian = createComicIfNotFound(aspen, "Fathom: Killians Tide", false, false); + Comic flakriot = createComicIfNotFound(image, "Flak Riot", false, false); + Comic freshmen = createComicIfNotFound(image, "Freshmen", false, false); + Comic friendlyneighborspider = createComicIfNotFound(marvel, "Friendly Neighborhood Spider-Man", false, false); + Comic futurama = createComicIfNotFound(bongo, "Futurama", false, false); + Comic futuramaco2 = createComicIfNotFound(bongo, "Futurama Simpsons Crossover Crisis Part 2", false, false); + Comic ghostrider = createComicIfNotFound(marvelknights, "Ghostrider", false, false); + Comic gift = createComicIfNotFound(image, "Gift", false, false); + Comic hackslashtoys = createComicIfNotFound(ddp, "Hack Slash Land of Lost Toys", false, false); + Comic hackslashgirlsgonedead = createComicIfNotFound(ddp, "Hack/Slash: Girls Gone Dead", false, false); + Comic harryjohnson = createComicIfNotFound(pulpfiction, "Harry Johnson", false, false); + Comic hellcop = createComicIfNotFound(image, "Hellcop", false, false); + Comic holidayspecial2004 = createComicIfNotFound(marvel, "Holiday Special 2004", false, false); + Comic housem = createComicIfNotFound(marvel, "House of M", false, false); + Comic hunterkiller = createComicIfNotFound(image, "Hunter-Killer", false, false); + Comic hunterkillerdossier = createComicIfNotFound(image, "Hunter-Killer Dossier", false, false); + Comic ironghost = createComicIfNotFound(image, "Iron Ghost", false, false); + Comic judge = createComicIfNotFound(image, "J.U.D.G.E.: Secret Rage", false, false); + Comic kisskissbangbang = createComicIfNotFound(crossgen, "Kiss Kiss Bang Bang", false, false); + Comic legendofisis = createComicIfNotFound(alias, "Legend of Isis", false, false); + Comic loki = createComicIfNotFound(marvel, "Loki", false, false); + Comic lullaby = createComicIfNotFound(image, "Lullaby", false, false); + Comic magdalenavampirella2 = createComicIfNotFound(topcow, "Magdalena / Vampirella 2", false, false); + Comic marvelknightspider = createComicIfNotFound(marvelknights, "Marvel Knights Spider-Man", false, false); + Comic marville = createComicIfNotFound(marvel, "Marville", false, false); + Comic maryjane = createComicIfNotFound(marvel, "Mary Jane", false, false); + Comic maryjanehome = createComicIfNotFound(marvel, "Mary Jane Homecoming", false, false); + Comic megacity = createComicIfNotFound(ddp, "Megacity 909", false, false); + Comic meridian = createComicIfNotFound(crossgen, "Meridian", false, false); + Comic midnightnation = createComicIfNotFound(image, "Midnight Nation", false, true); + Comic monsterwar = createComicIfNotFound(image, "Monster War", false, false); + Comic monsterwar2005 = createComicIfNotFound(image, "Monster War 2005", false, false); + Comic mystic = createComicIfNotFound(crossgen, "Mystic", false, false); + Comic mystique = createComicIfNotFound(marvel, "Mystique", false, false); + Comic necromancer = createComicIfNotFound(image, "Necromancer", false, false); + Comic negationwar = createComicIfNotFound(crossgen, "Negation War", false, false); + Comic newavengers = createComicIfNotFound(marvel, "New Avengers", false, false); + Comic newmutants = createComicIfNotFound(marvel, "New Mutants", false, false); + Comic newxmen = createComicIfNotFound(marvel, "New X-Men", false, false); + Comic newxmenacademy = createComicIfNotFound(marvel, "New X-Men Academy X", false, false); + Comic newxmenhellions = createComicIfNotFound(marvel, "New X-Men Hellions", false, false); + Comic nightcrawler = createComicIfNotFound(marvel, "Nightcrawler", false, false); + Comic ororo = createComicIfNotFound(marvel, "Ororo: Before the Storm", false, false); + Comic radix = createComicIfNotFound(image, "Radix", false, false); + Comic redsonja = createComicIfNotFound(dynamite, "Red Sonja", false, false); + Comic revelations = createComicIfNotFound(darkhorse, "Revelations", false, false); + Comic rogue = createComicIfNotFound(marvel, "Rogue", false, false); + Comic ruse = createComicIfNotFound(crossgen, "Ruse", false, false); + Comic samurai = createComicIfNotFound(darkhorse, "Samurai: Heaven & Earth", false, false); + Comic scion = createComicIfNotFound(crossgen, "Scion", false, false); + Comic shanna = createComicIfNotFound(marvelknights, "Shanna, The She-Devil", false, false); + Comic shehulk = createComicIfNotFound(marvel, "She-Hulk", false, false); + Comic shehulk2 = createComicIfNotFound(marvel, "She-Hulk 2", false, false); + Comic shijunen = createComicIfNotFound(darkhorse, "Shi Ju-Nen", false, false); + Comic shrek = createComicIfNotFound(darkhorse, "Shrek", false, false); + Comic simpsons = createComicIfNotFound(bongo, "Simpsons", false, false); + Comic sojourn = createComicIfNotFound(crossgen, "Sojourn", false, false); + Comic solus = createComicIfNotFound(crossgen, "Solus", false, false); + Comic soulfire = createComicIfNotFound(aspen, "Soulfire", false, false); + Comic soulfirelight = createComicIfNotFound(aspen, "Soulfire Dying of the Light", false, false); + Comic spectacularspiderman = createComicIfNotFound(marvel, "Spectacular Spider-Man", false, false); + Comic spellbinders = createComicIfNotFound(marvel, "Spellbinders", false, false); + Comic spidermanindia = createComicIfNotFound(marvel, "Spider-Man India", false, false); + Comic spidermanlovesmary = createComicIfNotFound(marvel, "Spider-Man loves Mary Jane", false, false); + Comic spidermanteam = createComicIfNotFound(marvel, "Spider-Man Team Up", false, false); + Comic spidermanbreakout = createComicIfNotFound(marvel, "Spider-Man: Breakout", false, false); + Comic spidermanhousem = createComicIfNotFound(marvel, "Spider-Man: House of M", false, false); + Comic starwars = createComicIfNotFound(darkhorse, "Star Wars", false, false); + Comic stardustkid = createComicIfNotFound(image, "Stardust Kid", false, false); + Comic strange = createComicIfNotFound(marvel, "Strange", false, false); + Comic supergirl = createComicIfNotFound(dc, "Supergirl", false, false); + Comic superman = createComicIfNotFound(dc, "Superman", false, false); + Comic supermanbatman = createComicIfNotFound(dc, "Superman/Batman", false, false); + Comic tarotblackrose = createComicIfNotFound(broadsword, "Tarot: Witch of the Black Rose", false, false); + Comic artgreghorn = createComicIfNotFound(image, "The Art of Greg Horn", false, false); + Comic devilskeeper = createComicIfNotFound(alias, "The Devil´s Keeper", false, false); + Comic thetenth = createComicIfNotFound(image, "The Tenth", false, false); + Comic tombdracula = createComicIfNotFound(marvel, "The Tomb of Dracula", false, false); + Comic robertjordanwheeloftime = createComicIfNotFound(redeagle, "Robert Jordan´s The Wheel of Time: New Spring", + false, false); + Comic thorsonasgard = createComicIfNotFound(marvel, "Thor: Son of Asgard", false, false); + Comic tomstrong = createComicIfNotFound(wildstorm, "Tom Strong", false, false); + Comic tombraider = createComicIfNotFound(image, "Tomb Raider", false, false); + Comic tombraidergreatesttreasure = createComicIfNotFound(image, "Tomb Raider: The Greatest Treasure of All", + false, false); + Comic toxin = createComicIfNotFound(marvel, "Toxin", false, false); + Comic ultimatefantasticfour = createComicIfNotFound(marvel, "Ultimate Fantastic Four", false, false); + Comic ultimatespidermanannual = createComicIfNotFound(marvel, "Ultimate Spider-Man Annual", false, false); + Comic uncannyxmen = createComicIfNotFound(marvel, "Uncanny X-Men", false, false); + Comic vampirella = createComicIfNotFound(dynamite, "Vampirella", false, false); + Comic wildgirl = createComicIfNotFound(wildstorm, "Wild Girl", false, false); + Comic wildcats = createComicIfNotFound(wildstorm, "Wildcats: Nemesis", false, false); + Comic wildsiderz = createComicIfNotFound(wildstorm, "Wildsiderz", false, false); + Comic witchblade = createComicIfNotFound(image, "Witchblade", false, false); + Comic witchbladetombraider = createComicIfNotFound(image, "Witchblade / Tomb Raider", false, false); + Comic wolverine = createComicIfNotFound(marvel, "Wolverine: The End", false, false); + Comic woodboy = createComicIfNotFound(image, "Wood Boy", false, false); + Comic wraithborn = createComicIfNotFound(wildstorm, "Wraithborn", false, false); + Comic x23 = createComicIfNotFound(marvel, "X-23", false, false); + Comic xmenageofapocalypse = createComicIfNotFound(marvel, "X-Men: Age of Apocalypse", false, false); + Comic xmenageofapocalypseoneshot = createComicIfNotFound(marvel, "X-Men: Age of Apocalypse One Shot", false, + false); + Comic xmenkittypryde = createComicIfNotFound(marvel, "X-Men: Kitty Pryde", false, false); + Comic phoenix = createComicIfNotFound(marvel, "X-Men: Phoenix - Endsong", false, false); + Comic xtremexmen = createComicIfNotFound(marvel, "X-treme X-Men", false, false); + Comic armydarknessreanim = createComicIfNotFound(dynamite, "Army of Darkness vs. Re-Animator", false, false); + Comic runaways = createComicIfNotFound(marvel, "Runaways", false, false); + Comic crux = createComicIfNotFound(crossgen, "Crux", false, false); + Comic ariasoulmarket = createComicIfNotFound(image, "Aria: The Soul Market", false, false); + Comic ariasummerspell = createComicIfNotFound(image, "Aria: Summer´s Spell", false, false); + Comic ariaenchantment = createComicIfNotFound(image, "Aria: The Uses of Enchantment", false, false); + Comic armydarknessashes = createComicIfNotFound(darkhorse, "Army of Darkness: Ashes 2 Ashes", false, false); + Comic armydarknessshop = createComicIfNotFound(darkhorse, "Army of Darkness: Shop Till You Drop Dead", false, + false); + createComicIfNotFound(marvel, "X-Men", false, false); + createComicIfNotFound(image, "Bomb Queen II: Queen of Hearts", false, false); + createComicIfNotFound(image, "Bomb Queen III: The Good, The Bad and The Lovely", false, false); + createComicIfNotFound(image, "Bomb Queen IV: Suicide Bomber", false, false); + createComicIfNotFound(wildstorm, "Gen13", false, false); + createComicIfNotFound(aspen, "Iron & The Maiden", false, false); + createComicIfNotFound(darkhorse, "Star Wars: Rebellion", false, false); + createComicIfNotFound(darkhorse, "Star Wars: Knights of the Old Republic", false, false); + createComicIfNotFound(darkhorse, "Star Wars: Legacy", false, false); + createComicIfNotFound(darkhorse, "Star Wars: Dark Times", false, false); + createComicWorkIfNotFound(crossgencomic, michaelturner, writer); + createComicWorkIfNotFound(dangergirl, michaelturner, writer); + createComicWorkIfNotFound(ultimatefantasticfour, michaelturner, writer); + createComicWorkIfNotFound(ultimatespidermanannual, michaelturner, writer); + createComicWorkIfNotFound(uncannyxmen, michaelturner, writer); + createComicWorkIfNotFound(starwars, michaelturner, writer); + createComicWorkIfNotFound(shehulk, michaelturner, writer); + createComicWorkIfNotFound(shehulk2, michaelturner, writer); + createComicWorkIfNotFound(scion, michaelturner, writer); + createComicWorkIfNotFound(newavengers, michaelturner, writer); + createComicWorkIfNotFound(newmutants, michaelturner, writer); + createComicWorkIfNotFound(midnightnation, michaelturner, writer); + createComicWorkIfNotFound(monsterwar, michaelturner, writer); + createComicWorkIfNotFound(monsterwar2005, michaelturner, writer); + createComicWorkIfNotFound(mystic, michaelturner, writer); + createComicWorkIfNotFound(holidayspecial2004, michaelturner, writer); + createComicWorkIfNotFound(hackslashgirlsgonedead, michaelturner, writer); + createComicWorkIfNotFound(uncannyxmen, brianbendis, writer); + createStoryArcIfNotFound("Higher Learning", emmafrost); + createStoryArcIfNotFound("Mind Games", emmafrost); + createStoryArcIfNotFound("Bloom", emmafrost); + createTradePaperbackIfNotFound("Vol. 1", midnightnation, 1, 12); + createTradePaperbackIfNotFound("From The Ashes", sojourn, 1, 6); + createTradePaperbackIfNotFound("The Dragons Tale", sojourn, 7, 12); + createTradePaperbackIfNotFound("The Warriors Tale", sojourn, 13, 18); + createTradePaperbackIfNotFound("The Thiefs Tale", sojourn, 19, 24); + createTradePaperbackIfNotFound("Vol. 1", runaways, 1, 18); + createTradePaperbackIfNotFound("Choices", gift, 1, 5); + createTradePaperbackIfNotFound("Atlantis Rising", crux, 1, 6); + createTradePaperbackIfNotFound("Test Of Time", crux, 7, 12); + createTradePaperbackIfNotFound("Strangers in Atlantis", crux, 13, 18); + createTradePaperbackIfNotFound("Flying Solo", meridian, 1, 7); + createTradePaperbackIfNotFound("Going To Ground", meridian, 8, 14); + createTradePaperbackIfNotFound("Taking The Skies", meridian, 15, 20); + createTradePaperbackIfNotFound("Coming Home", meridian, 21, 26); + createTradePaperbackIfNotFound("Rite of Passage", mystic, 1, 7); + createTradePaperbackIfNotFound("The Demon Queen", mystic, 8, 14); + createTradePaperbackIfNotFound("Siege of Scales", mystic, 15, 20); + createTradePaperbackIfNotFound("Out All Night", mystic, 21, 26); + createTradePaperbackIfNotFound("Single Green Female", shehulk, 1, 6); + createTradePaperbackIfNotFound("Superhuman Law", shehulk, 7, 12); + createTradePaperbackIfNotFound("Conflict of Conscience", scion, 1, 7); + createTradePaperbackIfNotFound("Blood For Blood", scion, 8, 14); + createTradePaperbackIfNotFound("Divided Loyalties", scion, 15, 21); + createTradePaperbackIfNotFound("Sanctuary", scion, 22, 27); + createTradePaperbackIfNotFound("The End Of History", uncannyxmen, 444, 449); + createTradePaperbackIfNotFound("Public Enemies", supermanbatman, 1, 6); + createTradePaperbackIfNotFound("Loyalty And Loss", crimson, 1, 6); + createTradePaperbackIfNotFound("Heaven & Earth", crimson, 7, 12); + createTradePaperbackIfNotFound("Earth Angel", crimson, 13, 18); + createTradePaperbackIfNotFound("Redemption", crimson, 19, 24); + createTradePaperbackIfNotFound("1602", comic1602, 1, 8); + createTradePaperbackIfNotFound("Coming Home", amazingspiderman, 30, 35); + createTradePaperbackIfNotFound("Revelations", amazingspiderman, 36, 39); + createTradePaperbackIfNotFound("Until the Stars Turn Cold", amazingspiderman, 40, 45); + createTradePaperbackIfNotFound("The Life & Death of Spiders", amazingspiderman, 46, 50); + createTradePaperbackIfNotFound("Unintended Consequences", amazingspiderman, 51, 56); + createTradePaperbackIfNotFound("Happy Birthday", amazingspiderman, 500, 502); + createTradePaperbackIfNotFound("Sonderband 1", dangergirl, 1, 2); + createTradePaperbackIfNotFound("Of Like Minds", birdsofprey, 56, 61); + createTradePaperbackIfNotFound("Sensei & Student", birdsofprey, 62, 68); + createIssueIfNotFound("1", phoenix, false, false); + createIssueIfNotFound("2", phoenix, false, false); + createIssueIfNotFound("3", phoenix, false, false); + createIssueIfNotFound("4", phoenix, false, false); + createIssueIfNotFound("5", phoenix, false, false); + createIssueIfNotFound("1", midnightnation, true, false); + createIssueIfNotFound("2", midnightnation, true, false); + createIssueIfNotFound("3", midnightnation, true, false); + createIssueIfNotFound("4", midnightnation, true, false); + createIssueIfNotFound("5", midnightnation, true, false); + createIssueIfNotFound("6", midnightnation, true, false); + createIssueIfNotFound("7", midnightnation, true, false); + createIssueIfNotFound("8", midnightnation, true, false); + createIssueIfNotFound("9", midnightnation, true, false); + createIssueIfNotFound("10", midnightnation, true, false); + createIssueIfNotFound("11", midnightnation, true, false); + createIssueIfNotFound("12", midnightnation, true, false); + createIssueIfNotFound("1", arana, false, false); + createIssueIfNotFound("2", arana, false, false); + createIssueIfNotFound("3", arana, false, false); + createIssueIfNotFound("4", arana, false, false); + createIssueIfNotFound("5", arana, false, false); + createIssueIfNotFound("6", arana, false, false); + createIssueIfNotFound("7", arana, false, false); + createIssueIfNotFound("8", arana, false, false); + createIssueIfNotFound("9", arana, false, false); + createIssueIfNotFound("10", arana, false, false); + createIssueIfNotFound("11", arana, false, false); + createIssueIfNotFound("1", futurama, false, false); + createIssueIfNotFound("2", futurama, false, false); + createIssueIfNotFound("3", futurama, false, false); + createIssueIfNotFound("4", futurama, false, false); + createIssueIfNotFound("5", futurama, false, false); + createIssueIfNotFound("6", futurama, false, false); + createIssueIfNotFound("7", futurama, false, false); + createIssueIfNotFound("8", futurama, false, false); + createIssueIfNotFound("9", futurama, false, false); + createIssueIfNotFound("10", futurama, false, false); + createIssueIfNotFound("11", futurama, false, false); + createIssueIfNotFound("12", futurama, false, false); + createIssueIfNotFound("13", futurama, false, false); + createIssueIfNotFound("14", futurama, false, false); + createIssueIfNotFound("15", futurama, false, false); + createIssueIfNotFound("16", futurama, false, false); + createIssueIfNotFound("17", futurama, false, false); + createIssueIfNotFound("18", futurama, false, false); + createIssueIfNotFound("19", futurama, false, false); + createIssueIfNotFound("20", futurama, false, false); + createIssueIfNotFound("21", futurama, false, false); + createIssueIfNotFound("22", futurama, false, false); + createIssueIfNotFound("1", futuramaco2, false, false); + createIssueIfNotFound("2", futuramaco2, false, false); + createIssueIfNotFound("1", battlepope, false, false); + createIssueIfNotFound("2", battlepope, false, false); + createIssueIfNotFound("3", battlepope, false, false); + createIssueIfNotFound("11", bartsimpsontree, false, false); + createIssueIfNotFound("20", bartsimpson, false, false); + createIssueIfNotFound("21", bartsimpson, false, false); + createIssueIfNotFound("22", bartsimpson, false, false); + createIssueIfNotFound("23", bartsimpson, false, false); + createIssueIfNotFound("24", bartsimpson, false, false); + createIssueIfNotFound("25", bartsimpson, false, false); + createIssueIfNotFound("1", athena, false, false); + createIssueIfNotFound("1", athenamanhunter, false, false); + createIssueIfNotFound("2", athenamanhunter, false, false); + createIssueIfNotFound("3", athenamanhunter, false, false); + createIssueIfNotFound("4", athenamanhunter, false, false); + createIssueIfNotFound("5", athenamanhunter, false, false); + createIssueIfNotFound("6", athenamanhunter, false, false); + createIssueIfNotFound("1", blackwidow, false, false); + createIssueIfNotFound("2", blackwidow, false, false); + createIssueIfNotFound("3", blackwidow, false, false); + createIssueIfNotFound("4", blackwidow, false, false); + createIssueIfNotFound("5", blackwidow, false, false); + createIssueIfNotFound("6", blackwidow, false, false); + createIssueIfNotFound("1", blackwidow2, false, false); + createIssueIfNotFound("2", blackwidow2, false, false); + createIssueIfNotFound("3", blackwidow2, false, false); + createIssueIfNotFound("4", blackwidow2, false, false); + createIssueIfNotFound("5", blackwidow2, false, false); + createIssueIfNotFound("1", ruse, false, false); + createIssueIfNotFound("2", ruse, false, false); + createIssueIfNotFound("3", ruse, false, false); + createIssueIfNotFound("4", ruse, false, false); + createIssueIfNotFound("5", ruse, false, false); + createIssueIfNotFound("6", ruse, false, false); + createIssueIfNotFound("7", ruse, false, false); + createIssueIfNotFound("8", ruse, false, false); + createIssueIfNotFound("9", ruse, false, false); + createIssueIfNotFound("10", ruse, false, false); + createIssueIfNotFound("11", ruse, false, false); + createIssueIfNotFound("12", ruse, false, false); + createIssueIfNotFound("13", ruse, false, false); + createIssueIfNotFound("14", ruse, false, false); + createIssueIfNotFound("15", ruse, false, false); + createIssueIfNotFound("16", ruse, false, false); + createIssueIfNotFound("17", ruse, false, false); + createIssueIfNotFound("18", ruse, false, false); + createIssueIfNotFound("19", ruse, false, false); + createIssueIfNotFound("20", ruse, false, false); + createIssueIfNotFound("21", ruse, false, false); + createIssueIfNotFound("22", ruse, false, false); + createIssueIfNotFound("23", ruse, false, false); + createIssueIfNotFound("24", ruse, false, false); + createIssueIfNotFound("25", ruse, false, false); + createIssueIfNotFound("26", ruse, false, false); + createIssueIfNotFound("1", samurai, false, false); + createIssueIfNotFound("2", samurai, false, false); + createIssueIfNotFound("3", samurai, false, false); + createIssueIfNotFound("4", samurai, false, false); + createIssueIfNotFound("1", amazingfantasy, false, false); + createIssueIfNotFound("2", amazingfantasy, false, false); + createIssueIfNotFound("3", amazingfantasy, false, false); + createIssueIfNotFound("4", amazingfantasy, false, false); + createIssueIfNotFound("5", amazingfantasy, false, false); + createIssueIfNotFound("6", amazingfantasy, false, false); + createIssueIfNotFound("7", amazingfantasy, false, false); + createIssueIfNotFound("8", amazingfantasy, false, false); + createIssueIfNotFound("9", amazingfantasy, false, false); + createIssueIfNotFound("10", amazingfantasy, false, false); + createIssueIfNotFound("11", amazingfantasy, false, false); + createIssueIfNotFound("12", amazingfantasy, false, false); + createIssueIfNotFound("13", amazingfantasy, false, false); + createIssueIfNotFound("14", amazingfantasy, false, false); + createIssueIfNotFound("15", amazingfantasy, false, false); + createIssueIfNotFound("1", excalibur, false, false); + createIssueIfNotFound("2", excalibur, false, false); + createIssueIfNotFound("3", excalibur, false, false); + createIssueIfNotFound("4", excalibur, false, false); + createIssueIfNotFound("5", excalibur, false, false); + createIssueIfNotFound("6", excalibur, false, false); + createIssueIfNotFound("7", excalibur, false, false); + createIssueIfNotFound("8", excalibur, false, false); + createIssueIfNotFound("9", excalibur, false, false); + createIssueIfNotFound("10", excalibur, false, false); + createIssueIfNotFound("11", excalibur, false, false); + createIssueIfNotFound("12", excalibur, false, false); + createIssueIfNotFound("1", emmafrost, false, false); + createIssueIfNotFound("2", emmafrost, false, false); + createIssueIfNotFound("3", emmafrost, false, false); + createIssueIfNotFound("4", emmafrost, false, false); + createIssueIfNotFound("5", emmafrost, false, false); + createIssueIfNotFound("6", emmafrost, false, false); + createIssueIfNotFound("7", emmafrost, false, false); + createIssueIfNotFound("8", emmafrost, false, false); + createIssueIfNotFound("9", emmafrost, false, false); + createIssueIfNotFound("10", emmafrost, false, false); + createIssueIfNotFound("11", emmafrost, false, false); + createIssueIfNotFound("12", emmafrost, false, false); + createIssueIfNotFound("13", emmafrost, false, false); + createIssueIfNotFound("14", emmafrost, false, false); + createIssueIfNotFound("15", emmafrost, false, false); + createIssueIfNotFound("16", emmafrost, false, false); + createIssueIfNotFound("17", emmafrost, false, false); + createIssueIfNotFound("18", emmafrost, false, false); + createIssueIfNotFound("1", catwomanrome, false, false); + createIssueIfNotFound("2", catwomanrome, false, false); + createIssueIfNotFound("3", catwomanrome, false, false); + createIssueIfNotFound("4", catwomanrome, false, false); + createIssueIfNotFound("5", catwomanrome, false, false); + createIssueIfNotFound("6", catwomanrome, false, false); + createIssueIfNotFound("1", districtx, false, false); + createIssueIfNotFound("2", districtx, false, false); + createIssueIfNotFound("3", districtx, false, false); + createIssueIfNotFound("4", districtx, false, false); + createIssueIfNotFound("5", districtx, false, false); + createIssueIfNotFound("6", districtx, false, false); + createIssueIfNotFound("7", districtx, false, false); + createIssueIfNotFound("8", districtx, false, false); + createIssueIfNotFound("9", districtx, false, false); + createIssueIfNotFound("10", districtx, false, false); + createIssueIfNotFound("11", districtx, false, false); + createIssueIfNotFound("12", districtx, false, false); + createIssueIfNotFound("13", districtx, false, false); + createIssueIfNotFound("14", districtx, false, false); + createIssueIfNotFound("1", elcazador, false, false); + createIssueIfNotFound("2", elcazador, false, false); + createIssueIfNotFound("3", elcazador, false, false); + createIssueIfNotFound("4", elcazador, false, false); + createIssueIfNotFound("5", elcazador, false, false); + createIssueIfNotFound("6", elcazador, false, false); + createIssueIfNotFound("1", elcazadortom, false, false); + createIssueIfNotFound("1", elsinore, false, false); + createIssueIfNotFound("1", dreampolice, false, false); + createIssueIfNotFound("1", dragonlance, false, false); + createIssueIfNotFound("2", dragonlance, false, false); + createIssueIfNotFound("1", ghostrider, false, false); + createIssueIfNotFound("2", ghostrider, false, false); + createIssueIfNotFound("3", ghostrider, false, false); + createIssueIfNotFound("4", ghostrider, false, false); + createIssueIfNotFound("5", ghostrider, false, false); + createIssueIfNotFound("6", ghostrider, false, false); + createIssueIfNotFound("1", fathomkillian, false, false); + createIssueIfNotFound("2", fathomkillian, false, false); + createIssueIfNotFound("3", fathomkillian, false, false); + createIssueIfNotFound("4", fathomkillian, false, false); + createIssueIfNotFound("1", maryjane, false, false); + createIssueIfNotFound("2", maryjane, false, false); + createIssueIfNotFound("3", maryjane, false, false); + createIssueIfNotFound("4", maryjane, false, false); + createIssueIfNotFound("1", maryjanehome, false, false); + createIssueIfNotFound("2", maryjanehome, false, false); + createIssueIfNotFound("3", maryjanehome, false, false); + createIssueIfNotFound("4", maryjanehome, false, false); + createIssueIfNotFound("1", marville, false, false); + createIssueIfNotFound("2", marville, false, false); + createIssueIfNotFound("3", marville, false, false); + createIssueIfNotFound("4", marville, false, false); + createIssueIfNotFound("5", marville, false, false); + createIssueIfNotFound("6", marville, false, false); + createIssueIfNotFound("7", marville, false, false); + createIssueIfNotFound("1", megacity, false, false); + createIssueIfNotFound("2", megacity, false, false); + createIssueIfNotFound("3", megacity, false, false); + createIssueIfNotFound("4", megacity, false, false); + createIssueIfNotFound("5", megacity, false, false); + createIssueIfNotFound("6", megacity, false, false); + createIssueIfNotFound("7", megacity, false, false); + createIssueIfNotFound("8", megacity, false, false); + createIssueIfNotFound("1", nightcrawler, false, false); + createIssueIfNotFound("2", nightcrawler, false, false); + createIssueIfNotFound("3", nightcrawler, false, false); + createIssueIfNotFound("4", nightcrawler, false, false); + createIssueIfNotFound("5", nightcrawler, false, false); + createIssueIfNotFound("6", nightcrawler, false, false); + createIssueIfNotFound("7", nightcrawler, false, false); + createIssueIfNotFound("8", nightcrawler, false, false); + createIssueIfNotFound("9", nightcrawler, false, false); + createIssueIfNotFound("10", nightcrawler, false, false); + createIssueIfNotFound("11", nightcrawler, false, false); + createIssueIfNotFound("12", nightcrawler, false, false); + createIssueIfNotFound("1", ororo, false, false); + createIssueIfNotFound("1", radix, false, false); + createIssueIfNotFound("2", radix, false, false); + createIssueIfNotFound("3", radix, false, false); + createIssueIfNotFound("1", rogue, false, false); + createIssueIfNotFound("2", rogue, false, false); + createIssueIfNotFound("3", rogue, false, false); + createIssueIfNotFound("4", rogue, false, false); + createIssueIfNotFound("5", rogue, false, false); + createIssueIfNotFound("6", rogue, false, false); + createIssueIfNotFound("7", rogue, false, false); + createIssueIfNotFound("8", rogue, false, false); + createIssueIfNotFound("9", rogue, false, false); + createIssueIfNotFound("10", rogue, false, false); + createIssueIfNotFound("11", rogue, false, false); + createIssueIfNotFound("12", rogue, false, false); + createIssueIfNotFound("1", shijunen, false, false); + createIssueIfNotFound("2", shijunen, false, false); + createIssueIfNotFound("3", shijunen, false, false); + createIssueIfNotFound("4", shijunen, false, false); + createIssueIfNotFound("1", solus, false, false); + createIssueIfNotFound("2", solus, false, false); + createIssueIfNotFound("3", solus, false, false); + createIssueIfNotFound("4", solus, false, false); + createIssueIfNotFound("5", solus, false, false); + createIssueIfNotFound("6", solus, false, false); + createIssueIfNotFound("7", solus, false, false); + createIssueIfNotFound("8", solus, false, false); + createIssueIfNotFound("1", toxin, false, false); + createIssueIfNotFound("2", toxin, false, false); + createIssueIfNotFound("3", toxin, false, false); + createIssueIfNotFound("4", toxin, false, false); + createIssueIfNotFound("5", toxin, false, false); + createIssueIfNotFound("6", toxin, false, false); + createIssueIfNotFound("1", wildgirl, false, false); + createIssueIfNotFound("2", wildgirl, false, false); + createIssueIfNotFound("3", wildgirl, false, false); + createIssueIfNotFound("4", wildgirl, false, false); + createIssueIfNotFound("5", wildgirl, false, false); + createIssueIfNotFound("6", wildgirl, false, false); + createIssueIfNotFound("1", wildcats, false, false); + createIssueIfNotFound("1", wildsiderz, false, false); + createIssueIfNotFound("19", vampirella, false, false); + createIssueIfNotFound("1", wolverine, false, false); + createIssueIfNotFound("2", wolverine, false, false); + createIssueIfNotFound("3", wolverine, false, false); + createIssueIfNotFound("4", wolverine, false, false); + createIssueIfNotFound("5", wolverine, false, false); + createIssueIfNotFound("6", wolverine, false, false); + createIssueIfNotFound("1", woodboy, false, false); + createIssueIfNotFound("1", wraithborn, false, false); + createIssueIfNotFound("2", wraithborn, false, false); + createIssueIfNotFound("3", wraithborn, false, false); + createIssueIfNotFound("1", x23, false, false); + createIssueIfNotFound("2", x23, false, false); + createIssueIfNotFound("3", x23, false, false); + createIssueIfNotFound("4", x23, false, false); + createIssueIfNotFound("5", x23, false, false); + createIssueIfNotFound("6", x23, false, false); + createIssueIfNotFound("46", xtremexmen, false, false); + createIssueIfNotFound("1", xmenkittypryde, false, false); + createIssueIfNotFound("2", xmenkittypryde, false, false); + createIssueIfNotFound("3", xmenkittypryde, false, false); + createIssueIfNotFound("4", xmenkittypryde, false, false); + createIssueIfNotFound("5", xmenkittypryde, false, false); + createIssueIfNotFound("1", witchbladetombraider, false, false); + createIssueIfNotFound("1", xmenageofapocalypseoneshot, false, false); + createIssueIfNotFound("1", xmenageofapocalypse, false, false); + createIssueIfNotFound("2", xmenageofapocalypse, false, false); + createIssueIfNotFound("3", xmenageofapocalypse, false, false); + createIssueIfNotFound("4", xmenageofapocalypse, false, false); + createIssueIfNotFound("5", xmenageofapocalypse, false, false); + createIssueIfNotFound("6", xmenageofapocalypse, false, false); + createIssueIfNotFound("1", thorsonasgard, false, false); + createIssueIfNotFound("2", thorsonasgard, false, false); + createIssueIfNotFound("3", thorsonasgard, false, false); + createIssueIfNotFound("4", thorsonasgard, false, false); + createIssueIfNotFound("5", thorsonasgard, false, false); + createIssueIfNotFound("6", thorsonasgard, false, false); + createIssueIfNotFound("7", thorsonasgard, false, false); + createIssueIfNotFound("8", thorsonasgard, false, false); + createIssueIfNotFound("9", thorsonasgard, false, false); + createIssueIfNotFound("10", thorsonasgard, false, false); + createIssueIfNotFound("11", thorsonasgard, false, false); + createIssueIfNotFound("12", thorsonasgard, false, false); + createIssueIfNotFound("1", strange, false, false); + createIssueIfNotFound("2", strange, false, false); + createIssueIfNotFound("3", strange, false, false); + createIssueIfNotFound("4", strange, false, false); + createIssueIfNotFound("5", strange, false, false); + createIssueIfNotFound("6", strange, false, false); + createIssueIfNotFound("0", supergirl, false, false); + createIssueIfNotFound("1", supergirl, false, false); + createIssueIfNotFound("2", supergirl, false, false); + createIssueIfNotFound("3", supergirl, false, false); + createIssueIfNotFound("4", supergirl, false, false); + createIssueIfNotFound("1", robertjordanwheeloftime, false, false); + createIssueIfNotFound("2", robertjordanwheeloftime, false, false); + createIssueIfNotFound("1", stardustkid, false, false); + createIssueIfNotFound("2", stardustkid, false, false); + createIssueIfNotFound("3", stardustkid, false, false); + createIssueIfNotFound("207", superman, false, false); + createIssueIfNotFound("208", superman, false, false); + createIssueIfNotFound("209", superman, false, false); + createIssueIfNotFound("210", superman, false, false); + createIssueIfNotFound("211", superman, false, false); + createIssueIfNotFound("212", superman, false, false); + createIssueIfNotFound("213", superman, false, false); + createIssueIfNotFound("214", superman, false, false); + createIssueIfNotFound("215", superman, false, false); + createIssueIfNotFound("17", supermanbatman, false, false); + createIssueIfNotFound("18", supermanbatman, false, false); + createIssueIfNotFound("19", supermanbatman, false, false); + createIssueIfNotFound("20", supermanbatman, false, false); + createIssueIfNotFound("21", supermanbatman, false, false); + createIssueIfNotFound("22", supermanbatman, false, false); + createIssueIfNotFound("1", tombraidergreatesttreasure, false, false); + createIssueIfNotFound("48", tombraider, false, false); + createIssueIfNotFound("49", tombraider, false, false); + createIssueIfNotFound("50", tombraider, false, false); + createIssueIfNotFound("1", tomstrong, false, false); + createIssueIfNotFound("1", tombdracula, false, false); + createIssueIfNotFound("1", thetenth, false, false); + createIssueIfNotFound("1", devilskeeper, false, false); + createIssueIfNotFound("1", artgreghorn, false, false); + createIssueIfNotFound("81", witchblade, false, false); + createIssueIfNotFound("82", witchblade, false, false); + createIssueIfNotFound("83", witchblade, false, false); + createIssueIfNotFound("84", witchblade, false, false); + createIssueIfNotFound("85", witchblade, false, false); + createIssueIfNotFound("86", witchblade, false, false); + createIssueIfNotFound("87", witchblade, false, false); + createIssueIfNotFound("88", witchblade, false, false); + createIssueIfNotFound("89", witchblade, false, false); + createIssueIfNotFound("90", witchblade, false, false); + createIssueIfNotFound("91", witchblade, false, false); + createIssueIfNotFound("92", witchblade, false, false); + createIssueIfNotFound("19", tarotblackrose, false, false); + createIssueIfNotFound("20", tarotblackrose, false, false); + createIssueIfNotFound("21", tarotblackrose, false, false); + createIssueIfNotFound("22", tarotblackrose, false, false); + createIssueIfNotFound("23", tarotblackrose, false, false); + createIssueIfNotFound("24", tarotblackrose, false, false); + createIssueIfNotFound("25", tarotblackrose, false, false); + createIssueIfNotFound("26", tarotblackrose, false, false); + createIssueIfNotFound("27", tarotblackrose, false, false); + createIssueIfNotFound("28", tarotblackrose, false, false); + createIssueIfNotFound("29", tarotblackrose, false, false); + createIssueIfNotFound("30", tarotblackrose, false, false); + createIssueIfNotFound("31", tarotblackrose, false, false); + createIssueIfNotFound("32", tarotblackrose, false, false); + createIssueIfNotFound("33", tarotblackrose, false, false); + createIssueIfNotFound("34", tarotblackrose, false, false); + createIssueIfNotFound("35", tarotblackrose, false, false); + createIssueIfNotFound("1", spectacularspiderman, false, false); + createIssueIfNotFound("2", spectacularspiderman, false, false); + createIssueIfNotFound("3", spectacularspiderman, false, false); + createIssueIfNotFound("4", spectacularspiderman, false, false); + createIssueIfNotFound("5", spectacularspiderman, false, false); + createIssueIfNotFound("6", spectacularspiderman, false, false); + createIssueIfNotFound("7", spectacularspiderman, false, false); + createIssueIfNotFound("8", spectacularspiderman, false, false); + createIssueIfNotFound("9", spectacularspiderman, false, false); + createIssueIfNotFound("10", spectacularspiderman, false, false); + createIssueIfNotFound("11", spectacularspiderman, false, false); + createIssueIfNotFound("12", spectacularspiderman, false, false); + createIssueIfNotFound("13", spectacularspiderman, false, false); + createIssueIfNotFound("14", spectacularspiderman, false, false); + createIssueIfNotFound("15", spectacularspiderman, false, false); + createIssueIfNotFound("16", spectacularspiderman, false, false); + createIssueIfNotFound("17", spectacularspiderman, false, false); + createIssueIfNotFound("18", spectacularspiderman, false, false); + createIssueIfNotFound("19", spectacularspiderman, false, false); + createIssueIfNotFound("20", spectacularspiderman, false, false); + createIssueIfNotFound("21", spectacularspiderman, false, false); + createIssueIfNotFound("22", spectacularspiderman, false, false); + createIssueIfNotFound("23", spectacularspiderman, false, false); + createIssueIfNotFound("24", spectacularspiderman, false, false); + createIssueIfNotFound("25", spectacularspiderman, false, false); + createIssueIfNotFound("26", spectacularspiderman, false, false); + createIssueIfNotFound("1", soulfire, false, false); + createIssueIfNotFound("2", soulfire, false, false); + createIssueIfNotFound("3", soulfire, false, false); + createIssueIfNotFound("4", soulfire, false, false); + createIssueIfNotFound("5", soulfire, false, false); + createIssueIfNotFound("1", soulfirelight, false, false); + createIssueIfNotFound("2", soulfirelight, false, false); + createIssueIfNotFound("3", soulfirelight, false, false); + createIssueIfNotFound("4", soulfirelight, false, false); + createIssueIfNotFound("1", spellbinders, false, false); + createIssueIfNotFound("2", spellbinders, false, false); + createIssueIfNotFound("3", spellbinders, false, false); + createIssueIfNotFound("4", spellbinders, false, false); + createIssueIfNotFound("5", spellbinders, false, false); + createIssueIfNotFound("6", spellbinders, false, false); + createIssueIfNotFound("1", spidermanlovesmary, false, false); + createIssueIfNotFound("1", spidermanindia, false, false); + createIssueIfNotFound("2", spidermanindia, false, false); + createIssueIfNotFound("3", spidermanindia, false, false); + createIssueIfNotFound("4", spidermanindia, false, false); + createIssueIfNotFound("1", spidermanteam, false, false); + createIssueIfNotFound("2", spidermanteam, false, false); + createIssueIfNotFound("3", spidermanteam, false, false); + createIssueIfNotFound("4", spidermanteam, false, false); + createIssueIfNotFound("5", spidermanteam, false, false); + createIssueIfNotFound("1", spidermanbreakout, false, false); + createIssueIfNotFound("2", spidermanbreakout, false, false); + createIssueIfNotFound("3", spidermanbreakout, false, false); + createIssueIfNotFound("4", spidermanbreakout, false, false); + createIssueIfNotFound("5", spidermanbreakout, false, false); + createIssueIfNotFound("1", spidermanhousem, false, false); + createIssueIfNotFound("2", spidermanhousem, false, false); + createIssueIfNotFound("3", spidermanhousem, false, false); + createIssueIfNotFound("4", spidermanhousem, false, false); + createIssueIfNotFound("13", sojourn, false, false); + createIssueIfNotFound("14", sojourn, false, false); + createIssueIfNotFound("15", sojourn, false, false); + createIssueIfNotFound("16", sojourn, false, false); + createIssueIfNotFound("17", sojourn, false, false); + createIssueIfNotFound("18", sojourn, false, false); + createIssueIfNotFound("19", sojourn, false, false); + createIssueIfNotFound("20", sojourn, false, false); + createIssueIfNotFound("21", sojourn, false, false); + createIssueIfNotFound("22", sojourn, false, false); + createIssueIfNotFound("23", sojourn, false, false); + createIssueIfNotFound("24", sojourn, false, false); + createIssueIfNotFound("25", sojourn, false, false); + createIssueIfNotFound("26", sojourn, false, false); + createIssueIfNotFound("27", sojourn, false, false); + createIssueIfNotFound("28", sojourn, false, false); + createIssueIfNotFound("29", sojourn, false, false); + createIssueIfNotFound("30", sojourn, false, false); + createIssueIfNotFound("31", sojourn, false, false); + createIssueIfNotFound("32", sojourn, false, false); + createIssueIfNotFound("33", sojourn, false, false); + createIssueIfNotFound("34", sojourn, false, false); + createIssueIfNotFound("1", shrek, false, false); + createIssueIfNotFound("2", shrek, false, false); + createIssueIfNotFound("1", shanna, false, false); + createIssueIfNotFound("2", shanna, false, false); + createIssueIfNotFound("3", shanna, false, false); + createIssueIfNotFound("4", shanna, false, false); + createIssueIfNotFound("5", shanna, false, false); + createIssueIfNotFound("6", shanna, false, false); + createIssueIfNotFound("7", shanna, false, false); + createIssueIfNotFound("100", simpsons, false, false); + createIssueIfNotFound("101", simpsons, false, false); + createIssueIfNotFound("102", simpsons, false, false); + createIssueIfNotFound("103", simpsons, false, false); + createIssueIfNotFound("104", simpsons, false, false); + createIssueIfNotFound("105", simpsons, false, false); + createIssueIfNotFound("106", simpsons, false, false); + createIssueIfNotFound("107", simpsons, false, false); + createIssueIfNotFound("108", simpsons, false, false); + createIssueIfNotFound("109", simpsons, false, false); + createIssueIfNotFound("110", simpsons, false, false); + createIssueIfNotFound("111", simpsons, false, false); + createIssueIfNotFound("112", simpsons, false, false); + createIssueIfNotFound("1", newxmenhellions, false, false); + createIssueIfNotFound("2", newxmenhellions, false, false); + createIssueIfNotFound("3", newxmenhellions, false, false); + createIssueIfNotFound("4", newxmenhellions, false, false); + createIssueIfNotFound("1", newxmenacademy, false, false); + createIssueIfNotFound("2", newxmenacademy, false, false); + createIssueIfNotFound("3", newxmenacademy, false, false); + createIssueIfNotFound("4", newxmenacademy, false, false); + createIssueIfNotFound("5", newxmenacademy, false, false); + createIssueIfNotFound("6", newxmenacademy, false, false); + createIssueIfNotFound("7", newxmenacademy, false, false); + createIssueIfNotFound("8", newxmenacademy, false, false); + createIssueIfNotFound("9", newxmenacademy, false, false); + createIssueIfNotFound("10", newxmenacademy, false, false); + createIssueIfNotFound("11", newxmenacademy, false, false); + createIssueIfNotFound("12", newxmenacademy, false, false); + createIssueIfNotFound("13", newxmenacademy, false, false); + createIssueIfNotFound("14", newxmenacademy, false, false); + createIssueIfNotFound("15", newxmenacademy, false, false); + createIssueIfNotFound("16", newxmenacademy, false, false); + createIssueIfNotFound("151", newxmen, false, false); + createIssueIfNotFound("152", newxmen, false, false); + createIssueIfNotFound("153", newxmen, false, false); + createIssueIfNotFound("154", newxmen, false, false); + createIssueIfNotFound("1", redsonja, false, false); + createIssueIfNotFound("2", redsonja, false, false); + createIssueIfNotFound("3", redsonja, false, false); + createIssueIfNotFound("1", revelations, false, false); + createIssueIfNotFound("2", revelations, false, false); + createIssueIfNotFound("3", revelations, false, false); + createIssueIfNotFound("4", revelations, false, false); + createIssueIfNotFound("1", kisskissbangbang, false, false); + createIssueIfNotFound("2", kisskissbangbang, false, false); + createIssueIfNotFound("3", kisskissbangbang, false, false); + createIssueIfNotFound("4", kisskissbangbang, false, false); + createIssueIfNotFound("5", kisskissbangbang, false, false); + createIssueIfNotFound("1", loki, false, false); + createIssueIfNotFound("2", loki, false, false); + createIssueIfNotFound("3", loki, false, false); + createIssueIfNotFound("4", loki, false, false); + createIssueIfNotFound("1", lullaby, false, false); + createIssueIfNotFound("2", lullaby, false, false); + createIssueIfNotFound("3", lullaby, false, false); + createIssueIfNotFound("1", legendofisis, false, false); + createIssueIfNotFound("2", legendofisis, false, false); + createIssueIfNotFound("1", judge, false, false); + createIssueIfNotFound("2", judge, false, false); + createIssueIfNotFound("3", judge, false, false); + createIssueIfNotFound("1", friendlyneighborspider, false, false); + createIssueIfNotFound("2", friendlyneighborspider, false, false); + createIssueIfNotFound("3", friendlyneighborspider, false, false); + createIssueIfNotFound("1", gift, false, false); + createIssueIfNotFound("2", gift, false, false); + createIssueIfNotFound("3", gift, false, false); + createIssueIfNotFound("4", gift, false, false); + createIssueIfNotFound("5", gift, false, false); + createIssueIfNotFound("6", gift, false, false); + createIssueIfNotFound("7", gift, false, false); + createIssueIfNotFound("8", gift, false, false); + createIssueIfNotFound("9", gift, false, false); + createIssueIfNotFound("10", gift, false, false); + createIssueIfNotFound("11", gift, false, false); + createIssueIfNotFound("12", gift, false, false); + createIssueIfNotFound("13", gift, false, false); + createIssueIfNotFound("1/2", fathom, false, false); + createIssueIfNotFound("1", fathom, false, false); + createIssueIfNotFound("2", fathom, false, false); + createIssueIfNotFound("3", fathom, false, false); + createIssueIfNotFound("4", fathom, false, false); + createIssueIfNotFound("5", fathom, false, false); + createIssueIfNotFound("6", fathom, false, false); + createIssueIfNotFound("7", fathom, false, false); + createIssueIfNotFound("8", fathom, false, false); + createIssueIfNotFound("9", fathom, false, false); + createIssueIfNotFound("10", fathom, false, false); + createIssueIfNotFound("11", fathom, false, false); + createIssueIfNotFound("12", fathom, false, false); + createIssueIfNotFound("13", fathom, false, false); + createIssueIfNotFound("14", fathom, false, false); + createIssueIfNotFound("1", fathombeginnings, false, false); + createIssueIfNotFound("1", fathomcannon, false, false); + createIssueIfNotFound("2", fathomcannon, false, false); + createIssueIfNotFound("3", fathomcannon, false, false); + createIssueIfNotFound("1", fathomcannonprelude, false, false); + createIssueIfNotFound("1", fathomdawnofwar, false, false); + createIssueIfNotFound("2", fathomdawnofwar, false, false); + createIssueIfNotFound("3", fathomdawnofwar, false, false); + createIssueIfNotFound("1", fathomprelude, false, false); + createIssueIfNotFound("1", fathomswimsuit, false, false); + createIssueIfNotFound("1", fathomswimsuit2000, false, false); + createIssueIfNotFound("0", fathomvol2, false, false); + createIssueIfNotFound("1", fathomvol2, false, false); + createIssueIfNotFound("2", fathomvol2, false, false); + createIssueIfNotFound("3", fathomvol2, false, false); + createIssueIfNotFound("4", fathomvol2, false, false); + createIssueIfNotFound("5", fathomvol2, false, false); + createIssueIfNotFound("1", flakriot, false, false); + createIssueIfNotFound("1", freshmen, false, false); + createIssueIfNotFound("1", daringescapes, false, false); + createIssueIfNotFound("2", daringescapes, false, false); + createIssueIfNotFound("3", daringescapes, false, false); + createIssueIfNotFound("4", daringescapes, false, false); + createIssueIfNotFound("1", brath, false, false); + createIssueIfNotFound("2", brath, false, false); + createIssueIfNotFound("3", brath, false, false); + createIssueIfNotFound("4", brath, false, false); + createIssueIfNotFound("5", brath, false, false); + createIssueIfNotFound("6", brath, false, false); + createIssueIfNotFound("7", brath, false, false); + createIssueIfNotFound("8", brath, false, false); + createIssueIfNotFound("9", brath, false, false); + createIssueIfNotFound("10", brath, false, false); + createIssueIfNotFound("11", brath, false, false); + createIssueIfNotFound("12", brath, false, false); + createIssueIfNotFound("13", brath, false, false); + createIssueIfNotFound("14", brath, false, false); + createIssueIfNotFound("1", astonishingxmen, false, false); + createIssueIfNotFound("2", astonishingxmen, false, false); + createIssueIfNotFound("3", astonishingxmen, false, false); + createIssueIfNotFound("4", astonishingxmen, false, false); + createIssueIfNotFound("5", astonishingxmen, false, false); + createIssueIfNotFound("6", astonishingxmen, false, false); + createIssueIfNotFound("7", astonishingxmen, false, false); + createIssueIfNotFound("8", astonishingxmen, false, false); + createIssueIfNotFound("9", astonishingxmen, false, false); + createIssueIfNotFound("10", astonishingxmen, false, false); + createIssueIfNotFound("11", astonishingxmen, false, false); + createIssueIfNotFound("12", astonishingxmen, false, false); + createIssueIfNotFound("1", barbarossa, false, false); + createIssueIfNotFound("1", aspenComic, false, false); + createIssueIfNotFound("2", aspenComic, false, false); + createIssueIfNotFound("3", aspenComic, false, false); + createIssueIfNotFound("1", armydarknessreanim, false, false); + createIssueIfNotFound("2", armydarknessreanim, false, false); + createIssueIfNotFound("3", armydarknessreanim, false, false); + createIssueIfNotFound("1", hellcop, false, false); + createIssueIfNotFound("2", hellcop, false, false); + createIssueIfNotFound("3", hellcop, false, false); + createIssueIfNotFound("4", hellcop, false, false); + createIssueIfNotFound("1", housem, false, false); + createIssueIfNotFound("2", housem, false, false); + createIssueIfNotFound("3", housem, false, false); + createIssueIfNotFound("0", hunterkiller, false, false); + createIssueIfNotFound("1", hunterkiller, false, false); + createIssueIfNotFound("2", hunterkiller, false, false); + createIssueIfNotFound("3", hunterkiller, false, false); + createIssueIfNotFound("4", hunterkiller, false, false); + createIssueIfNotFound("1", hunterkillerdossier, false, false); + createIssueIfNotFound("1", ironghost, false, false); + createIssueIfNotFound("1", magdalenavampirella2, false, false); + createIssueIfNotFound("20", marvelknightspider, false, false); + createIssueIfNotFound("39", meridian, false, false); + createIssueIfNotFound("40", meridian, false, false); + createIssueIfNotFound("41", meridian, false, false); + createIssueIfNotFound("42", meridian, false, false); + createIssueIfNotFound("43", meridian, false, false); + createIssueIfNotFound("44", meridian, false, false); + createIssueIfNotFound("1", necromancer, false, false); + createIssueIfNotFound("2", necromancer, false, false); + createIssueIfNotFound("3", necromancer, false, false); + createIssueIfNotFound("1", negationwar, false, false); + createIssueIfNotFound("2", negationwar, false, false); + createIssueIfNotFound("1", mystique, false, false); + createIssueIfNotFound("2", mystique, false, false); + createIssueIfNotFound("3", mystique, false, false); + createIssueIfNotFound("4", mystique, false, false); + createIssueIfNotFound("5", mystique, false, false); + createIssueIfNotFound("6", mystique, false, false); + createIssueIfNotFound("7", mystique, false, false); + createIssueIfNotFound("8", mystique, false, false); + createIssueIfNotFound("9", mystique, false, false); + createIssueIfNotFound("10", mystique, false, false); + createIssueIfNotFound("11", mystique, false, false); + createIssueIfNotFound("12", mystique, false, false); + createIssueIfNotFound("13", mystique, false, false); + createIssueIfNotFound("14", mystique, false, false); + createIssueIfNotFound("15", mystique, false, false); + createIssueIfNotFound("16", mystique, false, false); + createIssueIfNotFound("17", mystique, false, false); + createIssueIfNotFound("18", mystique, false, false); + createIssueIfNotFound("19", mystique, false, false); + createIssueIfNotFound("20", mystique, false, false); + createIssueIfNotFound("21", mystique, false, false); + createIssueIfNotFound("22", mystique, false, false); + createIssueIfNotFound("23", mystique, false, false); + createIssueIfNotFound("24", mystique, false, false); + createIssueIfNotFound("1", abadazad, false, false); + createIssueIfNotFound("2", abadazad, false, false); + createIssueIfNotFound("3", abadazad, false, false); + createIssueIfNotFound("503", amazingspiderman, false, false); + createIssueIfNotFound("504", amazingspiderman, false, false); + createIssueIfNotFound("505", amazingspiderman, false, false); + createIssueIfNotFound("506", amazingspiderman, false, false); + createIssueIfNotFound("507", amazingspiderman, false, false); + createIssueIfNotFound("508", amazingspiderman, false, false); + createIssueIfNotFound("509", amazingspiderman, false, false); + createIssueIfNotFound("510", amazingspiderman, false, false); + createIssueIfNotFound("511", amazingspiderman, false, false); + createIssueIfNotFound("512", amazingspiderman, false, false); + createIssueIfNotFound("513", amazingspiderman, false, false); + createIssueIfNotFound("514", amazingspiderman, false, false); + createIssueIfNotFound("515", amazingspiderman, false, false); + createIssueIfNotFound("516", amazingspiderman, false, false); + createIssueIfNotFound("517", amazingspiderman, false, false); + createIssueIfNotFound("518", amazingspiderman, false, false); + createIssueIfNotFound("519", amazingspiderman, false, false); + createIssueIfNotFound("520", amazingspiderman, false, false); + createIssueIfNotFound("521", amazingspiderman, false, false); + createIssueIfNotFound("522", amazingspiderman, false, false); + createIssueIfNotFound("523", amazingspiderman, false, false); + createIssueIfNotFound("524", amazingspiderman, false, false); + createIssueIfNotFound("525", amazingspiderman, false, false); + createIssueIfNotFound("526", amazingspiderman, false, false); + createIssueIfNotFound("1", dangergirlbackinblack, false, false); + createIssueIfNotFound("2", dangergirlbackinblack, false, false); + createIssueIfNotFound("1", darknesssuperman, false, false); + createIssueIfNotFound("2", darknesssuperman, false, false); + createIssueIfNotFound("1", darknesstombraider, false, false); + createIssueIfNotFound("1", darknessvampirella, false, false); + createIssueIfNotFound("1", darknessblacksails, false, false); + createIssueIfNotFound("17", darknessvol2, false, false); + createIssueIfNotFound("18", darknessvol2, false, false); + createIssueIfNotFound("19", darknessvol2, false, false); + createIssueIfNotFound("20", darknessvol2, false, false); + createIssueIfNotFound("21", darknessvol2, false, false); + createIssueIfNotFound("22", darknessvol2, false, false); + createIssueIfNotFound("23", darknessvol2, false, false); + createIssueIfNotFound("1", aria, false, false); + createIssueIfNotFound("2", aria, false, false); + createIssueIfNotFound("3", aria, false, false); + createIssueIfNotFound("4", aria, false, false); + createIssueIfNotFound("1", ariasoulmarket, false, false); + createIssueIfNotFound("2", ariasoulmarket, false, false); + createIssueIfNotFound("3", ariasoulmarket, false, false); + createIssueIfNotFound("4", ariasoulmarket, false, false); + createIssueIfNotFound("5", ariasoulmarket, false, false); + createIssueIfNotFound("6", ariasoulmarket, false, false); + createIssueIfNotFound("1", ariasummerspell, false, false); + createIssueIfNotFound("2", ariasummerspell, false, false); + createIssueIfNotFound("1", ariaenchantment, false, false); + createIssueIfNotFound("2", ariaenchantment, false, false); + createIssueIfNotFound("3", ariaenchantment, false, false); + createIssueIfNotFound("4", ariaenchantment, false, false); + createIssueIfNotFound("1", armydarknessashes, false, false); + createIssueIfNotFound("2", armydarknessashes, false, false); + createIssueIfNotFound("3", armydarknessashes, false, false); + createIssueIfNotFound("4", armydarknessashes, false, false); + createIssueIfNotFound("1", armydarknessshop, false, false); + createIssueIfNotFound("2", armydarknessshop, false, false); + createIssueIfNotFound("1", hackslashtoys, false, false); + createIssueIfNotFound("2", hackslashtoys, false, false); + createIssueIfNotFound("1", harryjohnson, false, false); + createIssueIfNotFound("4", battlepope, false, false); + createIssueIfNotFound("5", battlepope, false, false); + createIssueIfNotFound("6", battlepope, false, false); + createIssueIfNotFound("7", battlepope, false, false); + createIssueIfNotFound("8", battlepope, false, false); + createIssueIfNotFound("9", battlepope, false, false); + createIssueIfNotFound("10", battlepope, false, false); + createIssueIfNotFound("11", battlepope, false, false); + createIssueIfNotFound("12", battlepope, false, false); + List volumes = volumeRepository.findAll(); + volumes.forEach(volume -> volumeRepository.delete(volume)); + moduleService.setDataImported(ComicConstants.COMICS); + } + + private Artist createArtistIfNotFound(String artistName) { + log.info("createArtistIfNotFound {}", artistName); + Artist artist = artistRepository.findByName(artistName); + if (artist == null) { + log.info("Artist {} not found, will create it", artistName); + artist = new Artist(); + artist.setName(artistName); + artistRepository.save(artist); + } + return artist; + } + + private Publisher createPublisherIfNotFound(String publisherName) { + log.info("createPublisherIfNotFound {}", publisherName); + Publisher publisher = publisherRepository.findByName(publisherName); + if (publisher == null) { + log.info("Publisher {} not found, will create it", publisherName); + publisher = new Publisher(); + publisher.setName(publisherName); + publisherRepository.save(publisher); + } + return publisher; + } + + private Worktype createWorktypeIfNotFound(String workTypeName) { + log.info("createWorktypeIfNotFound {}", workTypeName); + Worktype worktype = worktypeRepository.findByName(workTypeName); + if (worktype == null) { + log.info("Worktype {} not found, will create it", workTypeName); + worktype = new Worktype(); + worktype.setName(workTypeName); + worktypeRepository.save(worktype); + } + return worktype; + } + + private Comic createComicIfNotFound(Publisher publisher, String title, boolean currentOrder, boolean completed) { + log.info("createComicIfNotFound {} {} {} {}", publisher, title, currentOrder, completed); + Comic comic = comicRepository.findByTitleAndPublisher(title, publisher); + if (comic == null) { + log.info("Comic {} from {} not found, will create it", title, publisher.getName()); + comic = new Comic(); + comic.setTitle(title); + comic.setPublisher(publisher); + comic.setCurrentOrder(currentOrder); + comic.setCompleted(completed); + comicRepository.save(comic); + } + return comic; + } + + private ComicWork createComicWorkIfNotFound(Comic comic, Artist artist, Worktype worktype) { + log.info("createComicWorkIfNotFound {} {} {}", comic, artist, worktype); + ComicWork comicWork = comicWorkRepository.findbyComicAndArtistAndWorktype(comic, artist, worktype); + if (comicWork == null) { + log.info("ComicWork {} from {} for {} not found, will create it", worktype, artist, comic); + comicWork = new ComicWork(); + comicWork.setComic(comic); + comicWork.setArtist(artist); + comicWork.setWorkType(worktype); + comicWorkRepository.save(comicWork); + } + return comicWork; + } + + private void createStoryArcIfNotFound(String name, Comic comic) { + log.info("createStoryArcIfNotFound {} {}", comic, name); + StoryArc storyArc = storyArcRepository.findByNameAndComic(name, comic); + if (storyArc == null) { + log.info("StoryArc {} for {} not found, will create it", name, comic); + storyArc = new StoryArc(); + storyArc.setName(name); + storyArc.setComic(comic); + storyArcRepository.save(storyArc); + } + } + + private void createTradePaperbackIfNotFound(String name, Comic comic, int start, int end) { + log.info("createTradePaperbackIfNotFound {} {} {}-{}", comic, name, start, end); + TradePaperback tradePaperback = tradePaperbackRepository.findByFields(name, comic, start, end); + if (tradePaperback == null) { + log.info("TradePaperback {} for {} with issues {}-{} not found, will create it", name, comic, start, end); + tradePaperback = new TradePaperback(); + tradePaperback.setName(name); + tradePaperback.setComic(comic); + tradePaperback.setIssueStart(start); + tradePaperback.setIssueEnd(end); + tradePaperbackRepository.save(tradePaperback); + } + } + + private void createIssueIfNotFound(String issueNumber, Comic comic, boolean isRead, boolean inStock) { + log.info("createIssueIfNotFound {} {} {} {}", comic, issueNumber, isRead, inStock); + Issue issue = issueRepository.findByComicAndIssueNumber(comic, issueNumber); + if (issue == null) { + log.info("Issue {} for {} not found, will create it", issueNumber, comic); + issue = new Issue(); + issue.setIssueNumber(issueNumber); + issue.setComic(comic); + issue.setIsRead(isRead); + issue.setInStock(inStock); + issueRepository.save(issue); + } + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/Artist.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Artist.java new file mode 100644 index 0000000..6a3159d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Artist.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import io.micrometer.common.lang.Nullable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.validation.constraints.NotEmpty; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * Represents an artist in the system. + */ +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@Slf4j +@Entity +public class Artist extends AbstractEntity { + + @NotEmpty + @Column(unique = true) + private String name; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "artist", cascade = CascadeType.REFRESH, orphanRemoval = true) + @Nullable + List comicWorks; + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("Artist{"); + sb.append("name='").append(name).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/ArtistRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ArtistRepository.java new file mode 100644 index 0000000..a5b94f2 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ArtistRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ArtistRepository extends JpaRepository { + @Query("select a from Artist a " + + "where lower(a.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + List findByNameIgnoreCase(String name); + + Artist findByName(String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/Comic.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Comic.java new file mode 100644 index 0000000..bad2914 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Comic.java @@ -0,0 +1,77 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.LinkedList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import io.micrometer.common.lang.Nullable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * Represents a comic entity. + */ +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@Entity +public class Comic extends AbstractEntity { + + @NotEmpty + @Column(unique = true) + private String title; + + @ManyToOne + @JoinColumn(name = "publisher_id") + @NotNull + @JsonIgnoreProperties({ "comics" }) + private Publisher publisher; + + private Boolean currentOrder = false; + + private Boolean completed = false; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "comic", cascade = CascadeType.REFRESH, orphanRemoval = true) + @Nullable + List comicWorks; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "comic", cascade = CascadeType.REFRESH, orphanRemoval = true) + @Nullable + private List issues = new LinkedList<>(); + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "comic", cascade = CascadeType.REFRESH, orphanRemoval = true) + @Nullable + private List storyArcs = new LinkedList<>(); + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "comic", cascade = CascadeType.REFRESH, orphanRemoval = true) + @Nullable + private List tradePaperbacks = new LinkedList<>(); + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "comic", cascade = CascadeType.REFRESH, orphanRemoval = true) + @Nullable + private List volumes = new LinkedList<>(); + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("Comic{"); + sb.append("title='").append(title).append('\''); + sb.append(", publisher=").append(publisher); + sb.append(", currentOrder=").append(currentOrder); + sb.append(", completed=").append(completed); + sb.append('}'); + return sb.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicRepository.java new file mode 100644 index 0000000..3a3e61f --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicRepository.java @@ -0,0 +1,19 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ComicRepository extends JpaRepository { + @Query("select c from Comic c " + + "where lower(c.title) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + Comic findByTitleAndPublisher(String title, Publisher publisher); + + List findByTitle(String title); + + List findByTitleIgnoreCase(String title); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWork.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWork.java new file mode 100644 index 0000000..840f93c --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWork.java @@ -0,0 +1,48 @@ +package de.thpeetz.kontor.comics.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@Entity +@Table(indexes = {@Index(columnList = "comic_id, artist_id, workType_id") }, + uniqueConstraints = @UniqueConstraint(columnNames = {"comic_id", "artist_id", "workType_id" }) +) +public class ComicWork extends AbstractEntity { + + @ManyToOne + @JoinColumn(name = "comic_id") + @NotNull + private Comic comic; + + @ManyToOne + @JoinColumn(name = "artist_id") + @NotNull + private Artist artist; + + @ManyToOne + @JoinColumn(name = "workType_id") + @NotNull + private Worktype workType; + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ComicWork{"); + sb.append("comic=").append(comic); + sb.append(", artist=").append(artist); + sb.append(", workType=").append(workType); + sb.append('}'); + return sb.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWorkRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWorkRepository.java new file mode 100644 index 0000000..6b3540a --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/ComicWorkRepository.java @@ -0,0 +1,15 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface ComicWorkRepository extends JpaRepository { + + @Query("SELECT c from ComicWork c where c.comic = ?1 and c.artist = ?2 and c.workType = ?3") + ComicWork findbyComicAndArtistAndWorktype(Comic comic, Artist artist, Worktype worktype); + + @Query("select c from ComicWork c where c.comic = ?1") + List findByComic(Comic comic); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/Issue.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Issue.java new file mode 100644 index 0000000..5a36f7d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Issue.java @@ -0,0 +1,42 @@ +package de.thpeetz.kontor.comics.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import io.micrometer.common.lang.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = false) +@Entity +public class Issue extends AbstractEntity { + + @ManyToOne + @JoinColumn(name = "comic_id") + @NotNull + @JsonIgnoreProperties({ "issues" }) + private Comic comic; + + @ManyToOne + @JoinColumn(name = "volume_id") + @JsonIgnoreProperties({ "issues" }) + @Nullable + private Volume volume; + + @NotEmpty + private String issueNumber; + + private Boolean isRead; + + private Boolean inStock; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/IssueRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/IssueRepository.java new file mode 100644 index 0000000..af10705 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/IssueRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface IssueRepository extends JpaRepository { + @Query("select i from Issue i " + + "where lower(i.issueNumber) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + List findByComic(Comic comic); + + Issue findByComicAndIssueNumber(Comic comic, String issueNumber); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/Publisher.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Publisher.java new file mode 100644 index 0000000..233d405 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Publisher.java @@ -0,0 +1,40 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.LinkedList; +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.validation.constraints.NotEmpty; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@Entity +public class Publisher extends AbstractEntity { + + @NotEmpty + @Column(unique = true) + private String name; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "publisher", cascade = CascadeType.ALL, orphanRemoval = true) + @Nullable + private List comics = new LinkedList<>(); + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("Publisher{"); + sb.append("name='").append(name).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/PublisherRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/PublisherRepository.java new file mode 100644 index 0000000..5b5587d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/PublisherRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PublisherRepository extends JpaRepository { + @Query("select p from Publisher p " + + "where lower(p.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + Publisher findByName(String name); + + List findByNameIgnoreCase(String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArc.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArc.java new file mode 100644 index 0000000..34f2188 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArc.java @@ -0,0 +1,31 @@ +package de.thpeetz.kontor.comics.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = false) +@Entity +public class StoryArc extends AbstractEntity { + + @NotEmpty + private String name; + + @ManyToOne + @JoinColumn(name = "comic_id") + @NotNull + @JsonIgnoreProperties({ "storyArcs" }) + private Comic comic; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArcRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArcRepository.java new file mode 100644 index 0000000..a74619f --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/StoryArcRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StoryArcRepository extends JpaRepository { + @Query("select s from StoryArc s " + + "where lower(s.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + StoryArc findByNameAndComic(String name, Comic comic); + + List findByComic(Comic comic); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperback.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperback.java new file mode 100644 index 0000000..07ee8d3 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperback.java @@ -0,0 +1,32 @@ +package de.thpeetz.kontor.comics.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = false) +@Entity +public class TradePaperback extends AbstractEntity { + + @NotEmpty + private String name; + + @ManyToOne + @JoinColumn(name = "comic_id") + @NotNull + private Comic comic; + + private Integer issueStart; + + private Integer issueEnd; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperbackRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperbackRepository.java new file mode 100644 index 0000000..fba26ad --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/TradePaperbackRepository.java @@ -0,0 +1,20 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TradePaperbackRepository extends JpaRepository { + @Query("select t from TradePaperback t " + + "where lower(t.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + List findByComic(Comic comic); + + List findByNameAndComic(String name, Comic comic); + + @Query("select t from TradePaperback t where t.name = ?1 and t.comic = ?2 and t.issueStart = ?3 and t.issueEnd = ?4") + TradePaperback findByFields(String name, Comic comic, int start, int end); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/Volume.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Volume.java new file mode 100644 index 0000000..c8e1eb0 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Volume.java @@ -0,0 +1,42 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.LinkedList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import io.micrometer.common.lang.Nullable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper = false) +@Entity +public class Volume extends AbstractEntity { + + @NotEmpty + private String name; + + @ManyToOne + @JoinColumn(name = "comic_id") + @NotNull + @JsonIgnoreProperties({ "volumes" }) + private Comic comic; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "volume", cascade = CascadeType.REMOVE, orphanRemoval = true) + @Nullable + private List issues = new LinkedList<>(); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/VolumeRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/VolumeRepository.java new file mode 100644 index 0000000..3b70670 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/VolumeRepository.java @@ -0,0 +1,13 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VolumeRepository extends JpaRepository { + + List findByName(String name); + + List findByComic(Comic comic); + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/Worktype.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Worktype.java new file mode 100644 index 0000000..9c83133 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/Worktype.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.LinkedList; +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import io.micrometer.common.lang.Nullable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.validation.constraints.NotEmpty; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * Represents a work type in the application. + * This class extends the AbstractEntity class. + */ +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@Entity +public class Worktype extends AbstractEntity { + + @NotEmpty + @Column(unique = true) + private String name; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "workType", cascade = CascadeType.REFRESH, orphanRemoval = true) + @Nullable + List comicWorks = new LinkedList<>(); + + @Override + public String toString() { + final StringBuffer sb = new StringBuffer("Worktype{"); + sb.append("name='").append(name).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/data/WorktypeRepository.java b/springboot/src/main/java/de/thpeetz/kontor/comics/data/WorktypeRepository.java new file mode 100644 index 0000000..2afebaf --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/data/WorktypeRepository.java @@ -0,0 +1,18 @@ +package de.thpeetz.kontor.comics.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface WorktypeRepository extends JpaRepository { + @Query("select w from Worktype w " + + "where lower(w.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + + Worktype findByName(String name); + + List findByNameIgnoreCase(String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/services/ComicService.java b/springboot/src/main/java/de/thpeetz/kontor/comics/services/ComicService.java new file mode 100644 index 0000000..ae4637f --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/services/ComicService.java @@ -0,0 +1,299 @@ +package de.thpeetz.kontor.comics.services; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import de.thpeetz.kontor.comics.data.Artist; +import de.thpeetz.kontor.comics.data.ArtistRepository; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.ComicRepository; +import de.thpeetz.kontor.comics.data.ComicWork; +import de.thpeetz.kontor.comics.data.ComicWorkRepository; +import de.thpeetz.kontor.comics.data.Issue; +import de.thpeetz.kontor.comics.data.IssueRepository; +import de.thpeetz.kontor.comics.data.Publisher; +import de.thpeetz.kontor.comics.data.PublisherRepository; +import de.thpeetz.kontor.comics.data.StoryArc; +import de.thpeetz.kontor.comics.data.StoryArcRepository; +import de.thpeetz.kontor.comics.data.TradePaperback; +import de.thpeetz.kontor.comics.data.TradePaperbackRepository; +import de.thpeetz.kontor.comics.data.Volume; +import de.thpeetz.kontor.comics.data.VolumeRepository; +import de.thpeetz.kontor.comics.data.Worktype; +import de.thpeetz.kontor.comics.data.WorktypeRepository; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class ComicService { + + private final PublisherRepository publisherRepository; + private final ComicRepository comicRepository; + private final ArtistRepository artistRepository; + private final IssueRepository issueRepository; + private final StoryArcRepository storyArcRepository; + private final TradePaperbackRepository tradePaperbackRepository; + private final ComicWorkRepository comicWorkRepository; + private final VolumeRepository volumeRepository; + private final WorktypeRepository worktypeRepository; + + public ComicService(PublisherRepository publisherRepository, ComicRepository comicRepository, + ArtistRepository artistRepository, IssueRepository issueRepository, StoryArcRepository storyArcRepository, + TradePaperbackRepository tradePaperbackRepository, ComicWorkRepository comicWorkRepository, + VolumeRepository volumeRepository, WorktypeRepository worktypeRepository) { + + this.publisherRepository = publisherRepository; + this.comicRepository = comicRepository; + this.artistRepository = artistRepository; + this.issueRepository = issueRepository; + this.storyArcRepository = storyArcRepository; + this.tradePaperbackRepository = tradePaperbackRepository; + this.comicWorkRepository = comicWorkRepository; + this.volumeRepository = volumeRepository; + this.worktypeRepository = worktypeRepository; + } + + public List findAllPublishers(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return publisherRepository.findAll(); + } else { + return publisherRepository.search(stringFilter); + } + } + + public Publisher findPublisherByName(String publisherName) { + return publisherRepository.findByName(publisherName); + } + + public void deletePublisher(Publisher publisher) { + publisherRepository.delete(publisher); + } + + public void savePublisher(Publisher publisher) { + if (publisher == null) { + log.warn("Publisher is null. Are you sure you have connected your form to the application?"); + return; + } + publisherRepository.save(publisher); + } + + public List findAllComics(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return comicRepository.findAll(); + } else { + return comicRepository.search(stringFilter); + } + } + + public Comic findComicByTitle(String title) { + List comics = comicRepository.findByTitle(title); + if (comics.size() == 1) { + return comics.get(0); + } + return null; + } + + public void deleteComic(Comic comic) { + Publisher publisher = comic.getPublisher(); + publisher.getComics().remove(comic); + publisherRepository.save(publisher); + comicRepository.delete(comic); + } + + public void saveComic(Comic comic) { + if (comic == null) { + log.warn("Comic is null. Are you sure you have connected your form to the application?"); + return; + } + comicRepository.save(comic); + } + + public List findAllArtists(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return artistRepository.findAll(); + } else { + return artistRepository.search(stringFilter); + } + } + + public Artist findArtistByName(String artistName) { + if (artistName == null || artistName.isEmpty()) { + return null; + } else { + return artistRepository.findByName(artistName); + } + } + + public void deleteArtist(Artist artist) { + artistRepository.delete(artist); + } + + public void saveArtist(Artist artist) { + if (artist == null) { + log.warn("Artist is null. Are you sure you have connected your form to the application?"); + return; + } + artistRepository.save(artist); + } + + public List findAllIssues() { + return issueRepository.findAll(); + } + + public List findAllIssuesForComic(Comic comic) { + if (comic == null) { + return issueRepository.findAll(); + } else { + log.info("Find issues for Comic: {}", comic); + return issueRepository.findByComic(comic); + } + } + + public void saveIssue(Issue issue) { + if (issue == null) { + log.warn("Issue is null. Are you sure you have connected your form to the application?"); + return; + } + issueRepository.save(issue); + } + + public void deleteIssue(Issue issue) { + issueRepository.delete(issue); + } + + public List findAllStoryArcs() { + return storyArcRepository.findAll(); + } + + public List findAllStoryArcsForComic(Comic comic) { + if (comic == null) { + return storyArcRepository.findAll(); + } else { + log.info("Find Story Arc for Comic: {}", comic); + return storyArcRepository.findByComic(comic); + } + } + + public void saveStoryArc(StoryArc storyArc) { + storyArcRepository.save(storyArc); + } + + public void deleteStoryArc(StoryArc storyArc) { + Comic comic = storyArc.getComic(); + comic.getStoryArcs().remove(storyArc); + comicRepository.save(comic); + storyArcRepository.delete(storyArc); + } + + public List findAllTradePaperbacks(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return tradePaperbackRepository.findAll(); + } else { + return tradePaperbackRepository.search(stringFilter); + } + } + + public void deleteTradePaperBack(TradePaperback tradepaperback) { + tradePaperbackRepository.delete(tradepaperback); + } + + public void saveTradePaperBack(TradePaperback tradepaperback) { + if (tradepaperback == null) { + log.warn("TradePaperBack is null. Are you sure you have connected your form to the application?"); + return; + } + tradePaperbackRepository.save(tradepaperback); + } + + public List findAllComicWorks() { + return comicWorkRepository.findAll(); + } + + public void saveComicWork(ComicWork comicWork) { + if (comicWork == null) { + log.warn("ComicWork is null. Are you sure you have connected your form to the application?"); + return; + } + comicWorkRepository.save(comicWork); + } + + public void deleteComicWork(ComicWork comicWork) { + Comic comic = comicWork.getComic(); + comic.getComicWorks().remove(comicWork); + comicRepository.save(comic); + Artist artist = comicWork.getArtist(); + artist.getComicWorks().remove(comicWork); + artistRepository.save(artist); + Worktype worktype = comicWork.getWorkType(); + worktype.getComicWorks().remove(comicWork); + worktypeRepository.save(worktype); + comicWorkRepository.delete(comicWork); + } + + public List findAllVolumes() { + return volumeRepository.findAll(); + } + + public List findAllVolumesForComic(Comic comic) { + if (comic == null) { + return volumeRepository.findAll(); + } else { + log.info("Find Volume for Comic: {}", comic); + return volumeRepository.findByComic(comic); + } + } + + public void saveVolume(Volume volume) { + if (volume == null) { + log.warn("Volume is null. Are you sure you have connected your form to the application?"); + return; + } + volumeRepository.save(volume); + } + + public void deleteVolume(Volume volume) { + Comic comic = volume.getComic(); + comic.getVolumes().remove(volume); + comicRepository.save(comic); + volumeRepository.delete(volume); + } + + public List findAllWorktypes(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return worktypeRepository.findAll(); + } else { + return worktypeRepository.search(stringFilter); + } + } + + public Worktype findWorktypeByName(String worktypeName) { + if (worktypeName == null || worktypeName.isEmpty()) { + return null; + } else { + return worktypeRepository.findByName(worktypeName); + } + } + + public void saveWorktype(Worktype worktype) { + if (worktype == null) { + log.warn("Worktype is null. Are you sure you have connected your form to the application?"); + return; + } + worktypeRepository.save(worktype); + } + + public void deleteWorktype(Worktype worktype) { + List comicWorks = worktype.getComicWorks(); + if (comicWorks == null) { + log.warn("reference to ComicWork is null"); + return; + } + log.info("found {} references to ComicWork", comicWorks.size()); + comicWorks.forEach(comicWork -> { + comicWork.setWorkType(null); + }); + log.info("delete Worktype: {}", worktype); + worktypeRepository.delete(worktype); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistForm.java new file mode 100644 index 0000000..43a93d4 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistForm.java @@ -0,0 +1,117 @@ +package de.thpeetz.kontor.comics.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Artist; +import de.thpeetz.kontor.comics.data.ComicWork; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ArtistForm extends FormLayout { + + TextField name = new TextField("Name"); + Grid comicWorks = new Grid<>(ComicWork.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Artist.class); + + public ArtistForm() { + addClassName("artist-form"); + binder.bindInstanceFields(this); + + comicWorks.setColumns("workType.name", "comic.title"); + comicWorks.getColumnByKey("workType.name").setHeader("Work type"); + comicWorks.getColumnByKey("comic.title").setHeader("Comic"); + comicWorks.getColumns().forEach(col -> col.setAutoWidth(true)); + add(name, comicWorks, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setArtist(Artist artist) { + binder.setBean(artist); + } + + public void setComicWorks(List works) { + log.info("Setting comic works: {}", works); + this.comicWorks.setItems(works); + } + + public abstract static class ArtistFormEvent extends ComponentEvent { + private Artist artist; + + protected ArtistFormEvent(ArtistForm source, Artist artist) { + super(source, false); + this.artist = artist; + } + + public Artist getArtist() { + return artist; + } + } + + public static class SaveEvent extends ArtistFormEvent { + SaveEvent(ArtistForm source, Artist artist) { + super(source, artist); + } + } + + public static class DeleteEvent extends ArtistFormEvent { + DeleteEvent(ArtistForm source, Artist artist) { + super(source, artist); + } + } + + public static class CloseEvent extends ArtistFormEvent { + CloseEvent(ArtistForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistView.java new file mode 100644 index 0000000..f0d294d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ArtistView.java @@ -0,0 +1,129 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.data.Artist; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = "artist", layout = MainLayout.class) +@PageTitle("Artist | Comics | Kontor") +public class ArtistView extends VerticalLayout { + + Grid grid = new Grid<>(Artist.class); + TextField filterText = new TextField(); + ArtistForm form; + ComicService service; + + public ArtistView(ComicService service) { + this.service = service; + addClassName("artist-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + public ArtistForm getForm() { + return form; + } + + private void configureGrid() { + grid.addClassName("artist-grid"); + grid.setSizeFull(); + grid.setColumns("name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editArtist(event.getValue())); + } + + private void configureForm() { + form = new ArtistForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveArtist); + form.addDeleteListener(this::deleteArtist); + form.addCloseListener(e -> closeEditor()); + } + + private void saveArtist(ArtistForm.SaveEvent event) { + service.saveArtist(event.getArtist()); + updateList(); + closeEditor(); + } + + private void deleteArtist(ArtistForm.DeleteEvent event) { + service.deleteArtist(event.getArtist()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addArtistButton = new Button("Add artist"); + addArtistButton.addClickListener(click -> addArtist()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addArtistButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editArtist(Artist artist) { + if (artist == null) { + closeEditor(); + } else { + form.setArtist(artist); + form.setComicWorks(artist.getComicWorks()); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setArtist(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addArtist() { + grid.asSingleSelect().clear(); + editArtist(new Artist()); + } + + public void updateList() { + grid.setItems(service.findAllArtists(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicForm.java new file mode 100644 index 0000000..55fc557 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicForm.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.comics.views; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.ComicWork; +import de.thpeetz.kontor.comics.data.Publisher; + +public class ComicForm extends FormLayout { + + private static final Logger log = LoggerFactory.getLogger(ComicForm.class); + + TextField title = new TextField("Title"); + ComboBox publisher = new ComboBox<>("Publisher"); + Checkbox currentOrder = new Checkbox("Current order"); + Checkbox completed = new Checkbox("Completed"); + Grid comicWorks = new Grid<>(ComicWork.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Comic.class); + + public ComicForm(List publishers) { + addClassName("comic-form"); + binder.bindInstanceFields(this); + + publisher.setItems(publishers); + publisher.setItemLabelGenerator(Publisher::getName); + // comicWorks.addClassName("comic-works-grid"); + // comicWorks.setSizeFull(); + comicWorks.setColumns("workType.name", "artist.name"); + comicWorks.getColumnByKey("workType.name").setHeader("Work type"); + comicWorks.getColumnByKey("artist.name").setHeader("Artist"); + comicWorks.getColumns().forEach(col -> col.setAutoWidth(true)); + add(title, publisher, currentOrder, completed, comicWorks, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setComic(Comic comic) { + binder.setBean(comic); + } + + public void setComicWorks(List works) { + log.info("Setting comic works: {}", works); + comicWorks.setItems(works); + } + + public abstract static class ComicFormEvent extends ComponentEvent { + private Comic comic; + + protected ComicFormEvent(ComicForm source, Comic comic) { + super(source, false); + this.comic = comic; + } + + public Comic getComic() { + return comic; + } + } + + public static class SaveEvent extends ComicFormEvent { + SaveEvent(ComicForm source, Comic comic) { + super(source, comic); + } + } + + public static class DeleteEvent extends ComicFormEvent { + DeleteEvent(ComicForm source, Comic comic) { + super(source, comic); + } + } + + public static class CloseEvent extends ComicFormEvent { + CloseEvent(ComicForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicLayout.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicLayout.java new file mode 100644 index 0000000..cffdbdb --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicLayout.java @@ -0,0 +1,43 @@ +package de.thpeetz.kontor.comics.views; + +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.theme.lumo.LumoUtility; + +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.common.views.KontorLayoutUtil; +import de.thpeetz.kontor.security.SecurityService; +import lombok.extern.slf4j.Slf4j; + +/** + * Represents a custom layout for the comic view in the application. + * This layout extends the AppLayout class. + */ +@Slf4j +public class ComicLayout extends AppLayout { + + private final AdminService adminService; + + private final SecurityService securityService; + + public ComicLayout(AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + + KontorLayoutUtil layout = new KontorLayoutUtil(this, adminService, securityService); + layout.setSecondaryNavigation(getSecondaryNavigation()); + layout.createHeader(ComicConstants.COMICS); + } + + private HorizontalLayout getSecondaryNavigation() { + HorizontalLayout navigation = new HorizontalLayout(); + navigation.addClassNames(LumoUtility.JustifyContent.CENTER, LumoUtility.Gap.SMALL, LumoUtility.Height.MEDIUM); + navigation.add(ComicConstants.getComicLink(), ComicConstants.getPublisherLink(), ComicConstants.getIssueLink(), + ComicConstants.getTradePaperbackLink(), ComicConstants.getStoryArcLink(), + ComicConstants.getVolumeLink(), ComicConstants.getArtistLink(), ComicConstants.getComicWorkLink(), + ComicConstants.getWorktypeLink()); + return navigation; + } +} + diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicView.java new file mode 100644 index 0000000..ec09544 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicView.java @@ -0,0 +1,139 @@ +package de.thpeetz.kontor.comics.views; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; + +@Slf4j +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.COMICS_ROUTE, layout = MainLayout.class) +@PageTitle("Comic | Comics | Kontor") +public class ComicView extends VerticalLayout { + + Grid grid = new Grid<>(Comic.class); + TextField filterText = new TextField(); + ComicForm form; + ComicService service; + + public ComicView(ComicService service) { + this.service = service; + addClassName("comic-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("comic-grid"); + grid.setSizeFull(); + grid.setColumns("title", "publisher.name", "currentOrder", "completed"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editComic(event.getValue())); + } + + public ComicForm getForm() { + return form; + } + + private void configureForm() { + form = new ComicForm(service.findAllPublishers(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveComic); + form.addDeleteListener(this::deleteComic); + form.addCloseListener(e -> closeEditor()); + } + + private void saveComic(ComicForm.SaveEvent event) { + service.saveComic(event.getComic()); + updateList(); + closeEditor(); + } + + private void deleteComic(ComicForm.DeleteEvent event) { + service.deleteComic(event.getComic()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addComicButton = new Button("Add comic"); + addComicButton.addClickListener(click -> addComic()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addComicButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editComic(Comic comic) { + if (comic == null) { + closeEditor(); + } else { + form.setComic(comic); + if (comic.getComicWorks() == null) { + log.info("No comic works"); + } else { + log.info("Comic works sze: {}", comic.getComicWorks().size()); + } + form.setComicWorks(comic.getComicWorks()); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setComic(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addComic() { + grid.asSingleSelect().clear(); + editComic(new Comic()); + } + + public void updateList() { + grid.setItems(service.findAllComics(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkForm.java new file mode 100644 index 0000000..698a37e --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkForm.java @@ -0,0 +1,113 @@ +package de.thpeetz.kontor.comics.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Artist; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.ComicWork; +import de.thpeetz.kontor.comics.data.Worktype; + +public class ComicWorkForm extends FormLayout { + ComboBox comic = new ComboBox<>("Comic"); + ComboBox artist = new ComboBox<>("Artist"); + ComboBox workType = new ComboBox<>("Worktype"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(ComicWork.class); + + public ComicWorkForm(List comics, List artists, List workTypes) { + addClassName("comicwork-form"); + binder.bindInstanceFields(this); + + comic.setItems(comics); + comic.setItemLabelGenerator(Comic::getTitle); + artist.setItems(artists); + artist.setItemLabelGenerator(Artist::getName); + workType.setItems(workTypes); + workType.setItemLabelGenerator(Worktype::getName); + add(comic, artist, workType, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setComicWork(ComicWork comicWork) { + binder.setBean(comicWork); + } + + public abstract static class ComicWorkFormEvent extends ComponentEvent { + private ComicWork comicWork; + + protected ComicWorkFormEvent(ComicWorkForm source, ComicWork comicWork) { + super(source, false); + this.comicWork = comicWork; + } + + public ComicWork getComicWork() { + return comicWork; + } + } + + public static class SaveEvent extends ComicWorkFormEvent { + SaveEvent(ComicWorkForm source, ComicWork comicWork) { + super(source, comicWork); + } + } + + public static class DeleteEvent extends ComicWorkFormEvent { + DeleteEvent(ComicWorkForm source, ComicWork comicWork) { + super(source, comicWork); + } + } + + public static class CloseEvent extends ComicWorkFormEvent { + CloseEvent(ComicWorkForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkView.java new file mode 100644 index 0000000..449a76c --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/ComicWorkView.java @@ -0,0 +1,122 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.ComicWork; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.COMICWORK_ROUTE, layout = MainLayout.class) +@PageTitle("ComicWork | Comics | Kontor") +public class ComicWorkView extends VerticalLayout { + + Grid grid = new Grid<>(ComicWork.class); + ComicWorkForm form; + ComicService service; + + public ComicWorkView(ComicService service) { + this.service = service; + addClassName("comicWork-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("comic-grid"); + grid.setSizeFull(); + grid.setColumns("comic.title", "artist.name", "workType.name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editComicWork(event.getValue())); + } + + public ComicWorkForm getForm() { + return form; + } + + private void configureForm() { + form = new ComicWorkForm(service.findAllComics(null), service.findAllArtists(null), + service.findAllWorktypes(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveComicWork); + form.addDeleteListener(this::deleteComicWork); + form.addCloseListener(e -> closeEditor()); + } + + private void saveComicWork(ComicWorkForm.SaveEvent event) { + service.saveComicWork(event.getComicWork()); + updateList(); + closeEditor(); + } + + private void deleteComicWork(ComicWorkForm.DeleteEvent event) { + service.deleteComicWork(event.getComicWork()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + Button addComicButton = new Button("Add ComicWork"); + addComicButton.addClickListener(click -> addComicWork()); + + HorizontalLayout toolbar = new HorizontalLayout(addComicButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editComicWork(ComicWork comicWork) { + if (comicWork == null) { + closeEditor(); + } else { + form.setComicWork(comicWork); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setComicWork(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addComicWork() { + grid.asSingleSelect().clear(); + editComicWork(new ComicWork()); + } + + public void updateList() { + grid.setItems(service.findAllComicWorks()); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueForm.java new file mode 100644 index 0000000..d537210 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueForm.java @@ -0,0 +1,113 @@ +package de.thpeetz.kontor.comics.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.Issue; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class IssueForm extends FormLayout { + + ComboBox comic = new ComboBox<>("Comic"); + TextField issueNumber = new TextField("Issue number"); + Checkbox isRead = new Checkbox("Read"); + Checkbox inStock = new Checkbox("In stock"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Issue.class); + + public IssueForm(List comics) { + addClassName("issue-form"); + binder.bindInstanceFields(this); + + comic.setItems(comics); + comic.setItemLabelGenerator(Comic::getTitle); + add(comic, issueNumber, isRead, inStock, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setIssue(Issue issue) { + binder.setBean(issue); + } + + public abstract static class IssueFormEvent extends ComponentEvent { + private Issue issue; + + protected IssueFormEvent(IssueForm source, Issue issue) { + super(source, false); + this.issue = issue; + } + + public Issue getIssue() { + return issue; + } + } + + public static class SaveEvent extends IssueFormEvent { + SaveEvent(IssueForm source, Issue issue) { + super(source, issue); + } + } + + public static class DeleteEvent extends IssueFormEvent { + DeleteEvent(IssueForm source, Issue issue) { + super(source, issue); + } + } + + public static class CloseEvent extends IssueFormEvent { + CloseEvent(IssueForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueView.java new file mode 100644 index 0000000..4b9d58c --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/IssueView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.Issue; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.ISSUE_ROUTE, layout = MainLayout.class) +@PageTitle("Issue | Comics | Kontor") +public class IssueView extends VerticalLayout { + + Grid grid = new Grid<>(Issue.class); + ComboBox comicFilter = new ComboBox<>("Comic"); + IssueForm form; + ComicService service; + + public IssueView(ComicService service) { + this.service = service; + addClassName("issue-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("issue-grid"); + grid.setSizeFull(); + grid.setColumns("comic.title", "issueNumber", "isRead", "inStock"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editIssue(event.getValue())); + } + + public IssueForm getForm() { + return form; + } + + private void configureForm() { + form = new IssueForm(service.findAllComics(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveIssue); + form.addDeleteListener(this::deleteIssue); + form.addCloseListener(e -> closeEditor()); + } + + private void saveIssue(IssueForm.SaveEvent event) { + service.saveIssue(event.getIssue()); + updateList(); + closeEditor(); + } + + private void deleteIssue(IssueForm.DeleteEvent event) { + service.deleteIssue(event.getIssue()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + comicFilter.setItems(service.findAllComics(null)); + comicFilter.setItemLabelGenerator(Comic::getTitle); + comicFilter.addValueChangeListener(e -> updateList()); + comicFilter.setClearButtonVisible(true); + + Button addIssueButton = new Button("Add issue", click -> addIssue()); + + HorizontalLayout toolbar = new HorizontalLayout(comicFilter, addIssueButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editIssue(Issue issue) { + if (issue == null) { + closeEditor(); + } else { + form.setIssue(issue); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setIssue(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addIssue() { + grid.asSingleSelect().clear(); + editIssue(new Issue()); + } + + private void updateList() { + grid.setItems(service.findAllIssuesForComic(comicFilter.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherForm.java new file mode 100644 index 0000000..dbd2f62 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherForm.java @@ -0,0 +1,100 @@ +package de.thpeetz.kontor.comics.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Publisher; + +public class PublisherForm extends FormLayout { + public TextField name = new TextField("Name"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Publisher.class); + + public PublisherForm() { + addClassName("publisher-form"); + binder.bindInstanceFields(this); + + add(name, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setPublisher(Publisher publisher) { + binder.setBean(publisher); + } + + public abstract static class PublisherFormEvent extends ComponentEvent { + private Publisher publisher; + + protected PublisherFormEvent(PublisherForm source, Publisher publisher) { + super(source, false); + this.publisher = publisher; + } + + public Publisher getPublisher() { + return publisher; + } + } + + public static class SaveEvent extends PublisherFormEvent { + SaveEvent(PublisherForm source, Publisher publisher) { + super(source, publisher); + } + } + + public static class DeleteEvent extends PublisherFormEvent { + DeleteEvent(PublisherForm source, Publisher publisher) { + super(source, publisher); + } + } + + public static class CloseEvent extends PublisherFormEvent { + CloseEvent(PublisherForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherView.java new file mode 100644 index 0000000..b716889 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/PublisherView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.Publisher; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.PUBLISHER_ROUTE, layout = MainLayout.class) +@PageTitle("Publisher | Comics | Kontor") +public class PublisherView extends VerticalLayout { + + Grid grid = new Grid<>(Publisher.class); + TextField filterText = new TextField(); + PublisherForm form; + + ComicService service; + + public PublisherView(ComicService service) { + this.service = service; + addClassName("publisher-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("publisher-grid"); + grid.setSizeFull(); + grid.setColumns("name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editPublisher(event.getValue())); + } + + private void configureForm() { + form = new PublisherForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::savePublisher); + form.addDeleteListener(this::deletePublisher); + form.addCloseListener(e -> closeEditor()); + } + + private void savePublisher(PublisherForm.SaveEvent event) { + service.savePublisher(event.getPublisher()); + updateList(); + closeEditor(); + } + + private void deletePublisher(PublisherForm.DeleteEvent event) { + service.deletePublisher(event.getPublisher()); + updateList(); + closeEditor(); + } + + public Grid getGrid() { + return grid; + } + + public PublisherForm getForm() { + return form; + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addPublisherButton = new Button("Add publisher"); + addPublisherButton.addClickListener(click -> addPublisher()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addPublisherButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editPublisher(Publisher publisher) { + if (publisher == null) { + closeEditor(); + } else { + form.setPublisher(publisher); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setPublisher(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addPublisher() { + grid.asSingleSelect().clear(); + editPublisher(new Publisher()); + } + + public void updateList() { + grid.setItems(service.findAllPublishers(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcForm.java new file mode 100644 index 0000000..786a792 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcForm.java @@ -0,0 +1,108 @@ +package de.thpeetz.kontor.comics.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.StoryArc; + +import java.util.List; + +public class StoryArcForm extends FormLayout { + + ComboBox comic = new ComboBox<>("Comic"); + TextField name = new TextField("Story Arc Name"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(StoryArc.class); + + public StoryArcForm(List comics) { + addClassName("storyarc-form"); + binder.bindInstanceFields(this); + + comic.setItems(comics); + comic.setItemLabelGenerator(Comic::getTitle); + add(comic, name, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setStoryArc(StoryArc storyArc) { + binder.setBean(storyArc); + } + + public abstract static class StoryArcFormEvent extends ComponentEvent { + private StoryArc storyArc; + + protected StoryArcFormEvent(StoryArcForm source, StoryArc storyArc) { + super(source, false); + this.storyArc = storyArc; + } + + public StoryArc getStoryArc() { + return storyArc; + } + } + + public static class SaveEvent extends StoryArcFormEvent { + SaveEvent(StoryArcForm source, StoryArc storyArc) { + super(source, storyArc); + } + } + + public static class DeleteEvent extends StoryArcFormEvent { + DeleteEvent(StoryArcForm source, StoryArc storyArc) { + super(source, storyArc); + } + } + + public static class CloseEvent extends StoryArcFormEvent { + CloseEvent(StoryArcForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcView.java new file mode 100644 index 0000000..6044383 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/StoryArcView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.StoryArc; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.STORYARC_ROTE, layout = MainLayout.class) +@PageTitle("StoryArc | Comics | Kontor") +public class StoryArcView extends VerticalLayout { + + Grid grid = new Grid<>(StoryArc.class); + ComboBox comicFilter = new ComboBox<>("Comic"); + StoryArcForm form; + ComicService service; + + public StoryArcView(ComicService service) { + this.service = service; + addClassName("storyarc-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + StoryArcForm getForm() { + return form; + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("storyarc-grid"); + grid.setSizeFull(); + grid.setColumns("comic.title", "name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editStoryArc(event.getValue())); + } + + private void configureForm() { + form = new StoryArcForm(service.findAllComics(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveStoryArc); + form.addDeleteListener(this::deleteStoryArc); + form.addCloseListener(e -> closeEditor()); + } + + private void saveStoryArc(StoryArcForm.SaveEvent event) { + service.saveStoryArc(event.getStoryArc()); + updateList(); + closeEditor(); + } + + private void deleteStoryArc(StoryArcForm.DeleteEvent event) { + service.deleteStoryArc(event.getStoryArc()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + comicFilter.setItems(service.findAllComics(null)); + comicFilter.setItemLabelGenerator(Comic::getTitle); + comicFilter.addValueChangeListener(e -> updateList()); + comicFilter.setClearButtonVisible(true); + + Button addStoryArcButton = new Button("Add StoryArc", click -> addStoryArc()); + + HorizontalLayout toolbar = new HorizontalLayout(comicFilter, addStoryArcButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editStoryArc(StoryArc storyArc) { + if (storyArc == null) { + closeEditor(); + } else { + form.setStoryArc(storyArc); + form.setVisible(true); + addClassName("editing"); + } + } + + public void closeEditor() { + form.setStoryArc(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addStoryArc() { + grid.asSingleSelect().clear(); + editStoryArc(new StoryArc()); + } + + private void updateList() { + grid.setItems(service.findAllStoryArcsForComic(comicFilter.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperBackForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperBackForm.java new file mode 100644 index 0000000..8a1b477 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperBackForm.java @@ -0,0 +1,109 @@ +package de.thpeetz.kontor.comics.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.TradePaperback; + +public class TradePaperBackForm extends FormLayout { + TextField name = new TextField("Name"); + ComboBox comic = new ComboBox<>("Comic"); + TextField issueStart = new TextField("Issue Start"); + TextField issueEnd = new TextField("Issue End"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(TradePaperback.class); + + public TradePaperBackForm(List comics) { + addClassName("tradepaperback-form"); + binder.bindInstanceFields(this); + + comic.setItems(comics); + comic.setItemLabelGenerator(Comic::getTitle); + add(name, comic, issueStart, issueEnd, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setTradePaperBack(TradePaperback tradepaperback) { + binder.setBean(tradepaperback); + } + + public abstract static class TradePaperBackFormEvent extends ComponentEvent { + private TradePaperback tradepaperback; + + protected TradePaperBackFormEvent(TradePaperBackForm source, TradePaperback tradepaperback) { + super(source, false); + this.tradepaperback = tradepaperback; + } + + public TradePaperback getTradePaperBack() { + return tradepaperback; + } + } + + public static class SaveEvent extends TradePaperBackFormEvent { + SaveEvent(TradePaperBackForm source, TradePaperback tradepaperback) { + super(source, tradepaperback); + } + } + + public static class DeleteEvent extends TradePaperBackFormEvent { + DeleteEvent(TradePaperBackForm source, TradePaperback tradepaperback) { + super(source, tradepaperback); + } + } + + public static class CloseEvent extends TradePaperBackFormEvent { + CloseEvent(TradePaperBackForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperbackView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperbackView.java new file mode 100644 index 0000000..0215234 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/TradePaperbackView.java @@ -0,0 +1,129 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.TradePaperback; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.TPB_ROUTE, layout = MainLayout.class) +@PageTitle("TradePaperBack | Comics | Kontor") +public class TradePaperbackView extends VerticalLayout { + + Grid grid = new Grid<>(TradePaperback.class); + TextField filterText = new TextField(); + TradePaperBackForm form; + ComicService service; + + public TradePaperbackView(ComicService service) { + this.service = service; + addClassName("tradepaperback-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("tradepaperback-grid"); + grid.setSizeFull(); + grid.setColumns("name", "comic.title", "issueStart", "issueEnd"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editTradePaperBack(event.getValue())); + } + + public TradePaperBackForm getForm() { + return form; + } + + private void configureForm() { + form = new TradePaperBackForm(service.findAllComics(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveTradePaperBack); + form.addDeleteListener(this::deleteTradePaperBack); + form.addCloseListener(e -> closeEditor()); + } + + private void saveTradePaperBack(TradePaperBackForm.SaveEvent event) { + service.saveTradePaperBack(event.getTradePaperBack()); + updateList(); + closeEditor(); + } + + private void deleteTradePaperBack(TradePaperBackForm.DeleteEvent event) { + service.deleteTradePaperBack(event.getTradePaperBack()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addTradePaperBackButton = new Button("Add TradePaperBack"); + addTradePaperBackButton.addClickListener(click -> addTradePaperBack()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addTradePaperBackButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editTradePaperBack(TradePaperback tradepaperback) { + if (tradepaperback == null) { + closeEditor(); + } else { + form.setTradePaperBack(tradepaperback); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setTradePaperBack(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addTradePaperBack() { + grid.asSingleSelect().clear(); + editTradePaperBack(new TradePaperback()); + } + + public void updateList() { + grid.setItems(service.findAllTradePaperbacks(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeForm.java new file mode 100644 index 0000000..9b333a9 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeForm.java @@ -0,0 +1,109 @@ +package de.thpeetz.kontor.comics.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.StoryArc; +import de.thpeetz.kontor.comics.data.Volume; + +public class VolumeForm extends FormLayout { + + ComboBox comic = new ComboBox<>("Comic"); + public TextField name = new TextField("Name"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Volume.class); + + public VolumeForm(List comics) { + addClassName("volume-form"); + binder.bindInstanceFields(this); + + comic.setItems(comics); + comic.setItemLabelGenerator(Comic::getTitle); + add(comic, name, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setVolume(Volume volume) { + binder.setBean(volume); + } + + public abstract static class VolumeFormEvent extends ComponentEvent { + private Volume volume; + + protected VolumeFormEvent(VolumeForm source, Volume volume) { + super(source, false); + this.volume = volume; + } + + public Volume getVolume() { + return volume; + } + } + + public static class SaveEvent extends VolumeFormEvent { + SaveEvent(VolumeForm source, Volume volume) { + super(source, volume); + } + } + + public static class DeleteEvent extends VolumeFormEvent { + DeleteEvent(VolumeForm source, Volume volume) { + super(source, volume); + } + } + + public static class CloseEvent extends VolumeFormEvent { + CloseEvent(VolumeForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeView.java new file mode 100644 index 0000000..47a2201 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/VolumeView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.Volume; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.VOLUME_ROUTE, layout = MainLayout.class) +@PageTitle("Volume | Comics | Kontor") +public class VolumeView extends VerticalLayout { + + Grid grid = new Grid<>(Volume.class); + ComboBox comicFilter = new ComboBox<>("Comic"); + VolumeForm form; + ComicService service; + + public VolumeView(ComicService service) { + this.service = service; + addClassName("volume-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + VolumeForm getForm() { + return form; + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("volume-grid"); + grid.setSizeFull(); + grid.setColumns("comic.title", "name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editVolume(event.getValue())); + } + + private void configureForm() { + form = new VolumeForm(service.findAllComics(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveVolume); + form.addDeleteListener(this::deleteVolume); + form.addCloseListener(e -> closeEditor()); + } + + private void saveVolume(VolumeForm.SaveEvent event) { + service.saveVolume(event.getVolume()); + updateList(); + closeEditor(); + } + + private void deleteVolume(VolumeForm.DeleteEvent event) { + service.deleteVolume(event.getVolume()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + comicFilter.setItems(service.findAllComics(null)); + comicFilter.setItemLabelGenerator(Comic::getTitle); + comicFilter.addValueChangeListener(e -> updateList()); + comicFilter.setClearButtonVisible(true); + + Button addVolumeButton = new Button("Add Volume", click -> addVolume()); + + HorizontalLayout toolbar = new HorizontalLayout(comicFilter, addVolumeButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editVolume(Volume volume) { + if (volume == null) { + closeEditor(); + } else { + form.setVolume(volume); + form.setVisible(true); + addClassName("editing"); + } + } + + public void closeEditor() { + form.setVolume(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addVolume() { + grid.asSingleSelect().clear(); + editVolume(new Volume()); + } + + private void updateList() { + grid.setItems(service.findAllVolumesForComic(comicFilter.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeForm.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeForm.java new file mode 100644 index 0000000..0965229 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeForm.java @@ -0,0 +1,121 @@ +package de.thpeetz.kontor.comics.views; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.comics.data.ComicWork; +import de.thpeetz.kontor.comics.data.Worktype; + +public class WorktypeForm extends FormLayout { + + private static final Logger log = LoggerFactory.getLogger(WorktypeForm.class); + + TextField name = new TextField("Name"); + Grid comicWorks = new Grid<>(ComicWork.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Worktype.class); + + public WorktypeForm() { + addClassName("worktype-form"); + binder.bindInstanceFields(this); + + comicWorks.setColumns("comic.title", "artist.name"); + comicWorks.getColumnByKey("comic.title").setHeader("Comic"); + comicWorks.getColumnByKey("artist.name").setHeader("Artist"); + comicWorks.getColumns().forEach(col -> col.setAutoWidth(true)); + + add(name, comicWorks, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setWorktype(Worktype worktype) { + binder.setBean(worktype); + } + + public void setComicWorks(List works) { + log.info("Setting comic works: {}", works); + this.comicWorks.setItems(works); + } + + public abstract static class WorktypeFormEvent extends ComponentEvent { + private Worktype worktype; + + protected WorktypeFormEvent(WorktypeForm source, Worktype worktype) { + super(source, false); + this.worktype = worktype; + } + + public Worktype getWorktype() { + return worktype; + } + } + + public static class SaveEvent extends WorktypeFormEvent { + SaveEvent(WorktypeForm source, Worktype worktype) { + super(source, worktype); + } + } + + public static class DeleteEvent extends WorktypeFormEvent { + DeleteEvent(WorktypeForm source, Worktype worktype) { + super(source, worktype); + } + } + + public static class CloseEvent extends WorktypeFormEvent { + CloseEvent(WorktypeForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeView.java b/springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeView.java new file mode 100644 index 0000000..98f3adb --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/comics/views/WorktypeView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.comics.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.comics.data.Worktype; +import de.thpeetz.kontor.comics.services.ComicService; +import de.thpeetz.kontor.common.views.MainLayout; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = ComicConstants.WORKTYPE_ROUTE, layout = MainLayout.class) +@PageTitle("Worktype | Comics | Kontor") +public class WorktypeView extends VerticalLayout { + + Grid grid = new Grid<>(Worktype.class); + TextField filterText = new TextField(); + WorktypeForm form; + ComicService service; + + public WorktypeView(ComicService service) { + this.service = service; + addClassName("worktype-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("worktype-grid"); + grid.setSizeFull(); + grid.setColumns("name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editWorktype(event.getValue())); + } + + public WorktypeForm getForm() { + return form; + } + + private void configureForm() { + form = new WorktypeForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveWorktype); + form.addDeleteListener(this::deleteWorktype); + form.addCloseListener(e -> closeEditor()); + } + + private void saveWorktype(WorktypeForm.SaveEvent event) { + service.saveWorktype(event.getWorktype()); + updateList(); + closeEditor(); + } + + private void deleteWorktype(WorktypeForm.DeleteEvent event) { + service.deleteWorktype(event.getWorktype()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addWorktypeButton = new Button("Add worktype"); + addWorktypeButton.addClickListener(click -> addWorktype()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addWorktypeButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editWorktype(Worktype worktype) { + if (worktype == null) { + closeEditor(); + } else { + form.setWorktype(worktype); + form.setComicWorks(worktype.getComicWorks()); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setWorktype(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addWorktype() { + grid.asSingleSelect().clear(); + editWorktype(new Worktype()); + } + + public void updateList() { + grid.setItems(service.findAllWorktypes(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractEntity.java b/springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractEntity.java new file mode 100644 index 0000000..f9cfdaf --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractEntity.java @@ -0,0 +1,34 @@ +package de.thpeetz.kontor.common.data; + +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.util.Date; + +@Slf4j +@Getter +@Setter +@EqualsAndHashCode +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class AbstractEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Version + private int version; + + @CreatedDate + private Date createdDate; + + @LastModifiedDate + private Date lastModifiedDate; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractLinkEntity.java b/springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractLinkEntity.java new file mode 100644 index 0000000..7dfdf50 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/common/data/AbstractLinkEntity.java @@ -0,0 +1,55 @@ +package de.thpeetz.kontor.common.data; + +import jakarta.annotation.Nullable; +import jakarta.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.util.Date; + +@Slf4j +@Getter +@Setter +@EqualsAndHashCode +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class AbstractLinkEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Version + private int version; + + @CreatedDate + private Date createdDate; + + @LastModifiedDate + private Date lastModifiedDate; + + @Nullable + private String url; + + private boolean review; + + private boolean shouldDownload; + + @Nullable + private String title; + + @Nullable + private String cloudLink; + + @Nullable + private String fileName; + + @Nullable + private String path; + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/common/views/AvatarMenuBar.java b/springboot/src/main/java/de/thpeetz/kontor/common/views/AvatarMenuBar.java new file mode 100644 index 0000000..1678e58 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/common/views/AvatarMenuBar.java @@ -0,0 +1,52 @@ +package de.thpeetz.kontor.common.views; + +import com.vaadin.flow.component.avatar.Avatar; +import com.vaadin.flow.component.contextmenu.MenuItem; +import com.vaadin.flow.component.contextmenu.SubMenu; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.menubar.MenuBar; +import com.vaadin.flow.component.menubar.MenuBarVariant; +import com.vaadin.flow.router.Route; + +import com.vaadin.flow.router.Router; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.admin.views.UserProfileView; +import de.thpeetz.kontor.security.SecurityService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; + +@Slf4j +@Route("avatar-menu-bar") +public class AvatarMenuBar extends Div { + + private final SecurityService securityService; + + final AdminService adminService; + + public AvatarMenuBar(SecurityService securityService, AdminService adminService) { + this.adminService = adminService; + this.securityService = securityService; + + Avatar avatar = new Avatar(); + securityService.getAuthenticatedUser().ifPresent(user -> { + log.info("AdminService: {}", adminService); + if (user != null) { + String userName = user.getUsername(); + String fullName = adminService.getUserFullName(userName); + avatar.setName(fullName); + } + }); + // avatar.setImage(pictureUrl); + MenuBar menuBar = new MenuBar(); + menuBar.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE); + MenuItem menuItem = menuBar.addItem(avatar); + SubMenu subMenu = menuItem.getSubMenu(); + MenuItem logoutMenuItem = subMenu.addItem("Log out"); + logoutMenuItem.addClickListener(e -> securityService.logout()); + MenuItem profileItem = subMenu.addItem("Profile"); + profileItem.addClickListener(e -> profileItem.getUI().ifPresent(ui -> ui.navigate(UserProfileView.class))); + subMenu.addItem("Settings"); + subMenu.addItem("Help"); + add(menuBar); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/common/views/KontorLayoutUtil.java b/springboot/src/main/java/de/thpeetz/kontor/common/views/KontorLayoutUtil.java new file mode 100644 index 0000000..4c2112b --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/common/views/KontorLayoutUtil.java @@ -0,0 +1,100 @@ +package de.thpeetz.kontor.common.views; + +import com.vaadin.flow.component.accordion.Accordion; +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.applayout.AppLayout.Section; +import com.vaadin.flow.component.applayout.DrawerToggle; +import com.vaadin.flow.component.html.H1; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.orderedlayout.FlexComponent; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.Scroller; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.sidenav.SideNav; +import com.vaadin.flow.router.RouterLink; +import com.vaadin.flow.theme.lumo.LumoUtility; + +import de.thpeetz.kontor.admin.AdminConstants; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.bookshelf.BookshelfConstants; +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.security.SecurityService; +import de.thpeetz.kontor.tysc.TyscConstants; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class KontorLayoutUtil { + + private final AppLayout appLayout; + private HorizontalLayout secondaryNavigation; + + private AdminService adminService; + + private SecurityService securityService; + + public KontorLayoutUtil(AppLayout layout, AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + this.appLayout = layout; + } + + public void setSecondaryNavigation(HorizontalLayout secondaryNavigation) { + this.secondaryNavigation = secondaryNavigation; + } + + public void createHeader(String titleName) { + appLayout.addToDrawer(createTitle(), getScroller()); + appLayout.addToNavbar(getHeader(titleName)); + appLayout.setPrimarySection(Section.DRAWER); + } + + private H1 createTitle() { + H1 appTitle = new H1(new RouterLink("Kontor", MainView.class)); + appTitle.getStyle().set("font-size", "var(--lumo-font-size-l)") + .set("line-height", "var(--lumo-size-l)") + .set("margin", "0 var(--lumo-space-m)"); + return appTitle; + } + + private Scroller getScroller() { + Scroller scroller = new Scroller(getPrimaryNavigation()); + scroller.setClassName(LumoUtility.Padding.SMALL); + return scroller; + } + + private VerticalLayout getHeader(String header) { + DrawerToggle toggle = new DrawerToggle(); + H2 viewTitle = new H2(header); + viewTitle.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE); + HorizontalLayout subViews = this.secondaryNavigation; + + AvatarMenuBar avatar = new AvatarMenuBar(securityService, adminService); + HorizontalLayout wrapper = new HorizontalLayout(toggle, viewTitle, avatar); + wrapper.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER); + wrapper.expand(viewTitle); + wrapper.setWidthFull(); + wrapper.setSpacing(false); + + VerticalLayout viewHeader = new VerticalLayout(wrapper, subViews); + viewHeader.setPadding(false); + viewHeader.setSpacing(false); + return viewHeader; + } + + private SideNav getPrimaryNavigation() { + SideNav sideNav = new SideNav(); + sideNav.addItem(ComicConstants.getComicsNavigation()); + sideNav.addItem(TyscConstants.getTyscNavigation()); + sideNav.addItem(BookshelfConstants.getBookshelfNavigation()); + securityService.getAuthenticatedUser().ifPresent(user -> { + log.info("User {} found", user.getUsername()); + boolean isAdmin = user.getAuthorities().stream() + .anyMatch(grantedAuthority -> "ROLE_ADMIN".equals(grantedAuthority.getAuthority())); + log.info("User {} hat Admin-Rechte: {}", user, isAdmin); + if (isAdmin) { + sideNav.addItem(AdminConstants.getAdminNavigation()); + } + }); + return sideNav; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/common/views/MainLayout.java b/springboot/src/main/java/de/thpeetz/kontor/common/views/MainLayout.java new file mode 100644 index 0000000..96f720f --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/common/views/MainLayout.java @@ -0,0 +1,101 @@ +package de.thpeetz.kontor.common.views; + +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.applayout.DrawerToggle; +import com.vaadin.flow.component.html.H1; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.orderedlayout.FlexComponent; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.Scroller; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.sidenav.SideNav; +import com.vaadin.flow.component.sidenav.SideNavItem; +import com.vaadin.flow.router.RouterLink; +import com.vaadin.flow.theme.lumo.LumoUtility; + +import de.thpeetz.kontor.admin.AdminConstants; +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.bookshelf.BookshelfConstants; +import de.thpeetz.kontor.comics.ComicConstants; +import de.thpeetz.kontor.mailclient.views.EmailView; +import de.thpeetz.kontor.media.MediaConstants; +import de.thpeetz.kontor.security.SecurityService; +import de.thpeetz.kontor.tysc.TyscConstants; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; + +import java.util.ArrayList; +import java.util.Collection; + +@Slf4j +public class MainLayout extends AppLayout { + + private final AdminService adminService; + + private final SecurityService securityService; + + public MainLayout(AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + + createHeader("Kontor"); + createDrawer(); + } + + public void createDrawer() { + H1 appTitle = new H1(new RouterLink("Kontor", MainView.class)); + appTitle.getStyle().set("font-size", "var(--lumo-font-size-l)") + .set("line-height", "var(--lumo-size-l)") + .set("margin", "0 var(--lumo-space-m)"); + Scroller scroller = new Scroller(getPrimaryNavigation()); + scroller.setClassName(LumoUtility.Padding.SMALL); + addToDrawer(appTitle, scroller); + } + + public void createHeader(String titleName) { + DrawerToggle toggle = new DrawerToggle(); + H2 viewTitle = new H2(titleName); + viewTitle.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE); + + AvatarMenuBar avatar = new AvatarMenuBar(securityService, adminService); + HorizontalLayout wrapper = new HorizontalLayout(toggle, viewTitle, avatar); + wrapper.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.CENTER); + wrapper.expand(viewTitle); + wrapper.setWidthFull(); + wrapper.setSpacing(false); + + VerticalLayout viewHeader = new VerticalLayout(wrapper); + viewHeader.setPadding(false); + viewHeader.setSpacing(false); + + addToNavbar(viewHeader); + setPrimarySection(Section.DRAWER); + } + + private SideNav getPrimaryNavigation() { + SideNav sideNav = new SideNav(); + ArrayList roles = new ArrayList<>(); + securityService.getAuthenticatedUser().ifPresent(user -> { + log.info("Get roles for user {}", user.getUsername()); + user.getAuthorities().forEach(grantedAuthority -> { + roles.add(grantedAuthority.getAuthority()); + }); + log.info("user {} has roles: {}", user, roles); + }); + sideNav.addItem(ComicConstants.getComicsNavigation()); + sideNav.addItem(TyscConstants.getTyscNavigation()); + sideNav.addItem(BookshelfConstants.getBookshelfNavigation()); + sideNav.addItem(MediaConstants.getMediaNavigation(roles)); + sideNav.addItem(new SideNavItem("Emails", EmailView.class)); + securityService.getAuthenticatedUser().ifPresent(user -> { + log.info("User {} found", user.getUsername()); + boolean isAdmin = user.getAuthorities().stream() + .anyMatch(grantedAuthority -> "ROLE_ADMIN".equals(grantedAuthority.getAuthority())); + log.info("User {} hat Admin-Rechte: {}", user, isAdmin); + if (isAdmin) { + sideNav.addItem(AdminConstants.getAdminNavigation()); + } + }); + return sideNav; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/common/views/MainView.java b/springboot/src/main/java/de/thpeetz/kontor/common/views/MainView.java new file mode 100644 index 0000000..08edac9 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/common/views/MainView.java @@ -0,0 +1,18 @@ +package de.thpeetz.kontor.common.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.flow.spring.annotation.SpringComponent; + +@SpringComponent +@Scope("prototype") +@AnonymousAllowed +@Route(value = "/", layout = MainLayout.class) +@PageTitle("Kontor") +public class MainView extends VerticalLayout { + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/common/views/SeparateMainLayout.java b/springboot/src/main/java/de/thpeetz/kontor/common/views/SeparateMainLayout.java new file mode 100644 index 0000000..474d4a7 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/common/views/SeparateMainLayout.java @@ -0,0 +1,31 @@ +package de.thpeetz.kontor.common.views; + +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.theme.lumo.LumoUtility; + +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.security.SecurityService; + +public class SeparateMainLayout extends AppLayout { + + private final AdminService adminService; + + private final SecurityService securityService; + + public SeparateMainLayout(AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + + KontorLayoutUtil layout = new KontorLayoutUtil(this, adminService, securityService); + layout.setSecondaryNavigation(getSecondaryNavigation()); + layout.createHeader("Kontor"); + } + + private HorizontalLayout getSecondaryNavigation() { + HorizontalLayout navigation = new HorizontalLayout(); + navigation.addClassNames(LumoUtility.JustifyContent.CENTER, LumoUtility.Gap.SMALL, LumoUtility.Height.MEDIUM); + return navigation; + } + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/mailclient/data/Mail.java b/springboot/src/main/java/de/thpeetz/kontor/mailclient/data/Mail.java new file mode 100644 index 0000000..f3b80a8 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/mailclient/data/Mail.java @@ -0,0 +1,20 @@ +package de.thpeetz.kontor.mailclient.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper=false) +@Entity +public class Mail extends AbstractEntity { + + private String folder; + private String subject; + private String body; + private Date sentDate; + private Date receivedDate; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/mailclient/data/MailAccount.java b/springboot/src/main/java/de/thpeetz/kontor/mailclient/data/MailAccount.java new file mode 100644 index 0000000..840d8da --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/mailclient/data/MailAccount.java @@ -0,0 +1,29 @@ +package de.thpeetz.kontor.mailclient.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@Setter +@Entity +public class MailAccount extends AbstractEntity { + + @NotEmpty + private String host; + + private Integer port; + + @NotEmpty + private String protocol; + + private String userName; + + private String password; + + private Boolean startTls; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/mailclient/views/EmailView.java b/springboot/src/main/java/de/thpeetz/kontor/mailclient/views/EmailView.java new file mode 100644 index 0000000..4235dd1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/mailclient/views/EmailView.java @@ -0,0 +1,222 @@ +package de.thpeetz.kontor.mailclient.views; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Properties; + +import javax.mail.Address; +import javax.mail.Folder; +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.NoSuchProviderException; +import javax.mail.Session; +import javax.mail.Store; +import javax.mail.internet.InternetAddress; + +import com.sun.mail.imap.IMAPFolder; +import com.vaadin.flow.component.Composite; +import com.vaadin.flow.component.accordion.Accordion; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.menubar.MenuBar; +import com.vaadin.flow.component.messages.MessageList; +import com.vaadin.flow.component.messages.MessageListItem; +import com.vaadin.flow.component.orderedlayout.FlexComponent.Alignment; +import com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.sidenav.SideNav; +import com.vaadin.flow.component.sidenav.SideNavItem; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteAlias; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.flow.theme.lumo.LumoUtility.Gap; + +import de.thpeetz.kontor.mailclient.data.MailAccount; +import de.thpeetz.kontor.admin.services.MailService; +import de.thpeetz.kontor.common.views.MainLayout; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@PageTitle("Email") +@AnonymousAllowed +@Route(value = "mail", layout = MainLayout.class) +@RouteAlias(value = "kontor/mail", layout = MainLayout.class) +public class EmailView extends Composite { + + MenuBar menuBar = new MenuBar(); + Accordion accordion = new Accordion(); + SideNav folderList = new SideNav(); + MailService mailService; + + public EmailView(MailService mailService) { + this.mailService = mailService; + configureView(); + updateFolderList(); + } + + private void updateFolderList() { + List accounts = mailService.findAllMailAccounts(); + MailAccount account = accounts.get(0); + + Properties properties = new Properties(); + properties.put(String.format("mail.%s.host", account.getProtocol()), account.getHost()); + properties.put(String.format("mail.%s.port", account.getProtocol()), account.getPort().toString()); + properties.put("mail.imap.starttls.enable", "true"); + + Session session = Session.getDefaultInstance(properties); + Store store; + try { + store = session.getStore(account.getProtocol()); + store.connect(account.getUserName(), account.getPassword()); + Folder rootFolder = store.getDefaultFolder(); + Folder[] folders = rootFolder.list(); + for (Folder value : folders) { + SideNavItem folder = addSubFolder(value); + folderList.addItem(folder); + } + log.info("{} folders found", folders.length); + Folder folder = store.getFolder("INBOX"); + IMAPFolder imapFolder = (IMAPFolder) folder; + imapFolder.open(Folder.READ_ONLY); + Message[] messages = imapFolder.getMessages(); + log.info("Folder {} opened", folder.getFullName()); + int messageCount = folder.getMessageCount(); + log.info("{} messages found", messages.length); + for (Message message : messages) { + log.info("Message {}: {}, {}, {}", message.getMessageNumber(), message.getSubject(), message.getReceivedDate(), message.getSentDate()); + //log.info("Message: {}", message.getAllRecipients()); + Address[] addresses; + printAddress(message.getRecipients(Message.RecipientType.TO)); + printAddress(message.getRecipients(Message.RecipientType.CC)); + printAddress(message.getRecipients(Message.RecipientType.BCC)); + } + store.close(); + log.info("Connection closed"); + } catch (NoSuchProviderException e) { + throw new RuntimeException(e); + } catch (MessagingException e) { + throw new RuntimeException(e); + } + } + + private void printAddress(Address[] addresses) { + if (addresses != null) { + for (Address address: addresses) { + log.info("Address {}: {}", address.getType(), address.toString()); + InternetAddress internetAddress = (InternetAddress)address; + log.info("InternetAddress {}: {}", internetAddress.getPersonal(), internetAddress.getAddress()); + } + } + } + + private SideNavItem addSubFolder(Folder mailFolder) throws MessagingException { + log.debug("addSubFolder: {}", mailFolder.getName()); + Span counter = new Span(String.valueOf(mailFolder.getMessageCount())); + counter.getElement().getThemeList().add("badge contrast pill"); + counter.getElement().setAttribute("aria-label", String.format("%d unread messages", mailFolder.getMessageCount())); + SideNavItem folder = new SideNavItem(mailFolder.getName()); + folder.setSuffixComponent(counter); + Folder[] folders = mailFolder.list(); + log.info("{} subfolders found", folders.length); + for (Folder value : folders) { + SideNavItem subFolder = addSubFolder(value); + folder.addItem(subFolder); + } + return folder; + } + + private void configureView() { + HorizontalLayout layoutMenubar = new HorizontalLayout(); + HorizontalLayout layoutRow2 = new HorizontalLayout(); + VerticalLayout layoutColumn2 = new VerticalLayout(); + VerticalLayout layoutColumn3 = new VerticalLayout(); + MessageList messageList = new MessageList(); + VerticalLayout layoutColumn4 = new VerticalLayout(); + Paragraph textMedium = new Paragraph(); + getContent().setWidth("100%"); + getContent().getStyle().set("flex-grow", "1"); + layoutMenubar.addClassName(Gap.MEDIUM); + layoutMenubar.setWidth("100%"); + layoutMenubar.setHeight("min-content"); + menuBar.setWidth("min-content"); + setMenuBarSampleData(menuBar); + layoutRow2.addClassName(Gap.MEDIUM); + layoutRow2.setWidth("100%"); + layoutRow2.getStyle().set("flex-grow", "1"); + layoutColumn2.getStyle().set("flex-grow", "1"); + accordion.setWidth("100%"); + setAccordionSampleData(accordion); + layoutColumn3.setHeightFull(); + layoutRow2.setFlexGrow(1.0, layoutColumn3); + layoutColumn3.setWidth("100%"); + layoutColumn3.getStyle().set("flex-grow", "1"); + layoutColumn3.setJustifyContentMode(JustifyContentMode.START); + layoutColumn3.setAlignItems(Alignment.START); + messageList.setWidth("100%"); + setMessageListSampleData(messageList); + layoutColumn4.setWidthFull(); + layoutColumn3.setFlexGrow(1.0, layoutColumn4); + layoutColumn4.setWidth("100%"); + layoutColumn4.getStyle().set("flex-grow", "1"); + textMedium.setText( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."); + textMedium.setWidth("100%"); + textMedium.getStyle().set("font-size", "var(--lumo-font-size-m)"); + getContent().add(layoutMenubar); + layoutMenubar.add(menuBar); + getContent().add(layoutRow2); + layoutRow2.add(layoutColumn2); + layoutColumn2.add(folderList); + layoutRow2.add(layoutColumn3); + layoutColumn3.add(messageList); + layoutColumn3.add(layoutColumn4); + layoutColumn4.add(textMedium); + } + + private void setMenuBarSampleData(MenuBar menuBar) { + menuBar.addItem("View"); + menuBar.addItem("Edit"); + menuBar.addItem("Share"); + menuBar.addItem("Move"); + } + + private void setAccordionSampleData(Accordion accordion) { + Span name = new Span("Sophia Williams"); + Span email = new Span("sophia.williams@company.com"); + Span phone = new Span("(501) 555-9128"); + VerticalLayout personalInformationLayout = new VerticalLayout(name, email, phone); + personalInformationLayout.setSpacing(false); + personalInformationLayout.setPadding(false); + accordion.add("Personal information", personalInformationLayout); + Span street = new Span("4027 Amber Lake Canyon"); + Span zipCode = new Span("72333-5884 Cozy Nook"); + Span city = new Span("Arkansas"); + VerticalLayout billingAddressLayout = new VerticalLayout(); + billingAddressLayout.setSpacing(false); + billingAddressLayout.setPadding(false); + billingAddressLayout.add(street, zipCode, city); + accordion.add("Billing address", billingAddressLayout); + Span cardBrand = new Span("Mastercard"); + Span cardNumber = new Span("1234 5678 9012 3456"); + Span expiryDate = new Span("Expires 06/21"); + VerticalLayout paymentLayout = new VerticalLayout(); + paymentLayout.setSpacing(false); + paymentLayout.setPadding(false); + paymentLayout.add(cardBrand, cardNumber, expiryDate); + accordion.add("Payment", paymentLayout); + } + + private void setMessageListSampleData(MessageList messageList) { + MessageListItem message1 = new MessageListItem("Nature does not hurry, yet everything gets accomplished.", + LocalDateTime.now().minusDays(1).toInstant(ZoneOffset.UTC), "Matt Mambo"); + message1.setUserColorIndex(1); + MessageListItem message2 = new MessageListItem( + "Using your talent, hobby or profession in a way that makes you contribute with something good to this world is truly the way to go.", + LocalDateTime.now().minusMinutes(55).toInstant(ZoneOffset.UTC), "Linsey Listy"); + message2.setUserColorIndex(2); + messageList.setItems(message1, message2); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/MediaConstants.java b/springboot/src/main/java/de/thpeetz/kontor/media/MediaConstants.java new file mode 100644 index 0000000..866f7b3 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/MediaConstants.java @@ -0,0 +1,35 @@ +package de.thpeetz.kontor.media; + +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.sidenav.SideNavItem; +import de.thpeetz.kontor.media.views.MediaArticleView; +import de.thpeetz.kontor.media.views.MediaFileView; +import de.thpeetz.kontor.media.views.MediaVideoView; + +import java.util.ArrayList; + +public class MediaConstants { + + public static final String MEDIA = "Media"; + public static final String MEDIAFILE_ROUTE = "media/mediafile"; + public static final String MEDIAVIDEO_ROUTE = "media/mediavideo"; + public static final String MEDIAARTICLE_ROUTE = "media/mediaarticle"; + public static final String MEDIA_ROLE = "ROLE_MEDIA"; + private static final String MEDIAFILE = "Media Files"; + private static final String MEDIAVIDEO = "Media Videos"; + private static final String MEDIAARTICLE = "Media Article"; + + public static SideNavItem getMediaNavigation(ArrayList roles) { + SideNavItem media = new SideNavItem(MEDIA, MEDIAFILE_ROUTE, VaadinIcon.VIMEO.create()); + media.addItem(new SideNavItem(MEDIAVIDEO, MediaVideoView.class)); + media.addItem(new SideNavItem(MEDIAARTICLE, MediaArticleView.class)); + if (roles.contains(MEDIA_ROLE)) { + media.addItem(new SideNavItem(MEDIAFILE, MediaFileView.class)); + } + return media; + } + + private MediaConstants() { + // private constructor to hide the implicit public one + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/SetupModuleMedia.java b/springboot/src/main/java/de/thpeetz/kontor/media/SetupModuleMedia.java new file mode 100644 index 0000000..c9ce7a1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/SetupModuleMedia.java @@ -0,0 +1,39 @@ +package de.thpeetz.kontor.media; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.admin.services.ModuleService; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SetupModuleMedia implements ApplicationListener { + + boolean alreadySetup = false; + + @Autowired + private AdminService adminService; + + @Autowired + private ModuleService moduleService; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + adminService.addRole(MediaConstants.MEDIA_ROLE); + if (alreadySetup) { + log.info("SetupModuleMedia already executed, skipping"); + return; + } + if (!moduleService.importData(MediaConstants.MEDIA)) { + log.info("Module media should not setup data"); + return; + } + + log.info("Set up Media data"); + moduleService.setDataImported(MediaConstants.MEDIA); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticle.java b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticle.java new file mode 100644 index 0000000..c664a3a --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticle.java @@ -0,0 +1,29 @@ +package de.thpeetz.kontor.media.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@Setter +@Entity +@Table(indexes = @Index(columnList = "url"), uniqueConstraints = @UniqueConstraint(columnNames = {"url"})) +public class MediaArticle extends AbstractEntity { + + @NotEmpty + private String url; + + private boolean review; + + @Nullable + private String title; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticleRepository.java b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticleRepository.java new file mode 100644 index 0000000..a25702e --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaArticleRepository.java @@ -0,0 +1,13 @@ +package de.thpeetz.kontor.media.data; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MediaArticleRepository extends JpaRepository { + @Query("select m from MediaArticle m " + + "where lower(m.url) like lower(concat('%', :searchTerm, '%')) or lower(m.title) like lower(concat('%', :searchTerm, '%'))") + List search(@Param("searchTerm") String searchTerm); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFile.java b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFile.java new file mode 100644 index 0000000..fa7b934 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFile.java @@ -0,0 +1,40 @@ +package de.thpeetz.kontor.media.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@Entity +@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "url" }) }) +public class MediaFile extends AbstractEntity { + + @Nullable + private String url; + + private boolean review; + + private boolean shouldDownload; + + @Nullable + private String title; + + @Nullable + private String cloudLink; + + @Nullable + private String fileName; + + @Nullable + private String path; + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFileRepository.java b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFileRepository.java new file mode 100644 index 0000000..7db70e1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaFileRepository.java @@ -0,0 +1,14 @@ +package de.thpeetz.kontor.media.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MediaFileRepository extends JpaRepository { + @Query("select m from MediaFile m " + + "where lower(m.url) like lower(concat('%', :searchTerm, '%')) or lower(m.title) like lower(concat('%', :searchTerm, '%'))") + List search(@Param("searchTerm") String searchTerm); + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaLink.java b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaLink.java new file mode 100644 index 0000000..c70db48 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaLink.java @@ -0,0 +1,40 @@ +package de.thpeetz.kontor.media.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + + +@Slf4j +@Getter +@Setter +@Entity +@Table(indexes = @Index(columnList = "url"), uniqueConstraints = @UniqueConstraint(columnNames = {"url"})) +public class MediaLink extends AbstractEntity { + + @NotEmpty + private String url; + + private String title; + + private boolean review; + + @OneToOne + @JoinColumn(name = "video_id", referencedColumnName = "id") + @Nullable + private MediaVideo mediaVideo; + + @OneToOne + @JoinColumn(name = "article_id", referencedColumnName = "id") + @Nullable + private MediaArticle mediaArticle; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideo.java b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideo.java new file mode 100644 index 0000000..d093eb1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideo.java @@ -0,0 +1,40 @@ +package de.thpeetz.kontor.media.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Getter +@Setter +@EqualsAndHashCode(callSuper = false) +@Entity +@Table(uniqueConstraints = { @UniqueConstraint(columnNames = { "url" }) }) +public class MediaVideo extends AbstractEntity { + + @Nullable + private String url; + + private boolean review; + + private boolean shouldDownload; + + @Nullable + private String title; + + @Nullable + private String cloudLink; + + @Nullable + private String fileName; + + @Nullable + private String path; + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideoRepository.java b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideoRepository.java new file mode 100644 index 0000000..d898186 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/data/MediaVideoRepository.java @@ -0,0 +1,15 @@ +package de.thpeetz.kontor.media.data; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MediaVideoRepository extends JpaRepository { + + @Query("select m from MediaVideo m " + + "where lower(m.url) like lower(concat('%', :searchTerm, '%')) or lower(m.title) like lower(concat('%', :searchTerm, '%'))") + List search(@Param("searchTerm") String searchTerm); + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaArticleService.java b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaArticleService.java new file mode 100644 index 0000000..fc78d5d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaArticleService.java @@ -0,0 +1,42 @@ +package de.thpeetz.kontor.media.services; + +import de.thpeetz.kontor.media.data.MediaArticle; +import de.thpeetz.kontor.media.data.MediaArticleRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +public class MediaArticleService { + + private final MediaArticleRepository mediaArticleRepository; + + public MediaArticleService(MediaArticleRepository mediaArticleRepository) { + this.mediaArticleRepository = mediaArticleRepository; + } + + public List findAllMediaArticles(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + log.info("Found {} entries", mediaArticleRepository.count()); + return mediaArticleRepository.findAll(); + } else { + List results = mediaArticleRepository.search(stringFilter); + log.info("Found {} entries", results.size()); + return results; + } + } + + public void saveMediaArticle(MediaArticle mediaArticle) { + if (mediaArticle == null) { + log.warn("MediaArticle is null. Are you sure you have connected your form to the application?"); + return; + } + mediaArticleRepository.save(mediaArticle); + } + + public void deleteMediaArticle(MediaArticle mediaArticle) { + mediaArticleRepository.delete(mediaArticle); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java new file mode 100644 index 0000000..a7fe0a1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java @@ -0,0 +1,42 @@ +package de.thpeetz.kontor.media.services; + +import de.thpeetz.kontor.media.data.MediaFile; +import de.thpeetz.kontor.media.data.MediaFileRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +public class MediaFileService { + + private final MediaFileRepository mediaFileRepository; + + public MediaFileService(MediaFileRepository mediaFileRepository) { + this.mediaFileRepository = mediaFileRepository; + } + + public List findAllMediaFiles(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + log.info("Found " + mediaFileRepository.count()+ " entries"); + return mediaFileRepository.findAll(); + } else { + List results = mediaFileRepository.search(stringFilter); + log.info("Found " + results.size() + " entries"); + return results; + } + } + + public void saveMediaFile(MediaFile mediaFile) { + if (mediaFile == null) { + log.warn("MediaFile is null. Are you sure you have connected your form to the application?"); + return; + } + mediaFileRepository.save(mediaFile); + } + + public void deleteMediaFile(MediaFile mediaFile) { + mediaFileRepository.delete(mediaFile); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaVideoService.java b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaVideoService.java new file mode 100644 index 0000000..61299fd --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaVideoService.java @@ -0,0 +1,39 @@ +package de.thpeetz.kontor.media.services; + +import de.thpeetz.kontor.media.data.MediaVideo; +import de.thpeetz.kontor.media.data.MediaVideoRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +public class MediaVideoService { + + private final MediaVideoRepository mediaVideoRepository; + + public MediaVideoService(MediaVideoRepository mediaVideoRepository) { + this.mediaVideoRepository = mediaVideoRepository; + } + + public List findAllMediaVideos(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return mediaVideoRepository.findAll(); + } else { + return mediaVideoRepository.search(stringFilter); + } + } + + public void saveMediaVideo(MediaVideo mediaVideo) { + if (mediaVideo == null) { + log.warn("MediaFile is null. Are you sure you have connected your form to the application?"); + return; + } + mediaVideoRepository.save(mediaVideo); + } + + public void deleteMediaVideo(MediaVideo mediaVideo) { + mediaVideoRepository.delete(mediaVideo); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleForm.java b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleForm.java new file mode 100644 index 0000000..d658e4e --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleForm.java @@ -0,0 +1,108 @@ +package de.thpeetz.kontor.media.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.media.data.MediaArticle; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MediaArticleForm extends FormLayout { + + TextField url = new TextField("URL"); + TextField title = new TextField("Title"); + Checkbox review = new Checkbox("Review"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(MediaArticle.class); + + public MediaArticleForm() { + addClassName("mediaarticle-form"); + binder.bindInstanceFields(this); + add(url, 2); + add(title, 2); + add(review, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new MediaArticleForm.DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new MediaArticleForm.CloseEvent(this))); + + binder.addStatusChangeListener(event -> { + save.setEnabled(event.getBinder().isValid()); + }); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new MediaArticleForm.SaveEvent(this, binder.getBean())); + } + } + + public void setMediaArticle(MediaArticle mediaArticle) { + binder.setBean(mediaArticle); + } + + public abstract static class MediaArticleFormEvent extends ComponentEvent { + private MediaArticle mediaArticle; + + protected MediaArticleFormEvent(MediaArticleForm source, MediaArticle mediaArticle) { + super(source, false); + this.mediaArticle = mediaArticle; + } + + public MediaArticle getMediaArticle() { + return mediaArticle; + } + } + + public static class SaveEvent extends MediaArticleForm.MediaArticleFormEvent { + SaveEvent(MediaArticleForm source, MediaArticle mediaArticle) { + super(source, mediaArticle); + } + } + + public static class DeleteEvent extends MediaArticleForm.MediaArticleFormEvent { + DeleteEvent(MediaArticleForm source, MediaArticle mediaArticle) { + super(source, mediaArticle); + } + } + + public static class CloseEvent extends MediaArticleForm.MediaArticleFormEvent { + CloseEvent(MediaArticleForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(MediaArticleForm.DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(MediaArticleForm.SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(MediaArticleForm.CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleView.java b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleView.java new file mode 100644 index 0000000..67079d4 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaArticleView.java @@ -0,0 +1,171 @@ +package de.thpeetz.kontor.media.views; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.contextmenu.ContextMenu; +import com.vaadin.flow.component.contextmenu.MenuItem; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.media.MediaConstants; +import de.thpeetz.kontor.media.data.MediaArticle; +import de.thpeetz.kontor.media.services.MediaArticleService; +import jakarta.annotation.security.PermitAll; +import lombok.Getter; +import org.springframework.context.annotation.Scope; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = MediaConstants.MEDIAARTICLE_ROUTE, layout = MainLayout.class) +@PageTitle("MediaArticle | Media | Kontor") +public class MediaArticleView extends VerticalLayout { + + @Getter + Grid grid = new Grid<>(MediaArticle.class, false); + Grid.Column idColumn = grid.addColumn(MediaArticle::getId).setHeader("ID").setResizable(true); + Grid.Column urlColumn = grid.addColumn(MediaArticle::getUrl).setHeader("URL").setResizable(true).setSortable(true); + Grid.Column titleColumn = grid.addColumn(MediaArticle::getTitle).setHeader("Titel").setResizable(true).setSortable(true); + Grid.Column reviewColumn = grid.addComponentColumn(mediaArticle -> createStatusIcon(mediaArticle.isReview())) + .setHeader("Überprüfung") + .setWidth("6rem"); + TextField searchField = new TextField(); + @Getter + MediaArticleForm form; + MediaArticleService service; + + private static class ColumnToggleContextMenu extends ContextMenu { + public ColumnToggleContextMenu(Component target) { + super(target); + setOpenOnClick(true); + } + + void addColumnToggleItem(String label, Grid.Column column) { + MenuItem menuItem = this.addItem(label, e -> { + column.setVisible(e.getSource().isChecked()); + }); + menuItem.setCheckable(true); + menuItem.setChecked(column.isVisible()); + menuItem.setKeepOpen(true); + } + } + + public MediaArticleView(MediaArticleService service) { + this.service = service; + addClassName("mediaarticle-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("mediaarticle-grid"); + grid.setSizeFull(); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + idColumn.setVisible(false); + grid.asSingleSelect().addValueChangeListener(event -> editMediaArticle(event.getValue())); + } + + private void configureForm() { + form = new MediaArticleForm(); + form.setWidth("75em"); + form.setVisible(false); + form.addSaveListener(this::saveMediaArticle); + form.addDeleteListener(this::deleteMediaArticle); + form.addCloseListener(e -> closeEditor()); + } + + private void saveMediaArticle(MediaArticleForm.SaveEvent event) { + service.saveMediaArticle(event.getMediaArticle()); + updateList(); + closeEditor(); + } + + private Icon createStatusIcon(boolean status) { + Icon icon; + if (status) { + icon = VaadinIcon.CHECK.create(); + icon.getElement().getThemeList().add("badge success"); + } else { + icon = VaadinIcon.CLOSE_SMALL.create(); + icon.getElement().getThemeList().add("badge error"); + } + icon.getStyle().set("padding", "var(--lumo-space-xs"); + return icon; + } + + private void deleteMediaArticle(MediaArticleForm.DeleteEvent event) { + service.deleteMediaArticle(event.getMediaArticle()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + searchField.setPlaceholder("Search"); + searchField.setClearButtonVisible(true); + searchField.setPrefixComponent(new Icon(VaadinIcon.SEARCH)); + searchField.setValueChangeMode(ValueChangeMode.EAGER); + searchField.addValueChangeListener(e -> updateList()); + + Button addMediaArticleButton = new Button("Add MediaArticle"); + addMediaArticleButton.addClickListener(click -> addMediaArticle()); + + Button menuButton = new Button("Show/Hide Columns"); + menuButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + MediaArticleView.ColumnToggleContextMenu columnToggleContextMenu = new MediaArticleView.ColumnToggleContextMenu(menuButton); + columnToggleContextMenu.addColumnToggleItem("ID", idColumn); + columnToggleContextMenu.addColumnToggleItem("URL", urlColumn); + columnToggleContextMenu.addColumnToggleItem("Titel", titleColumn); + columnToggleContextMenu.addColumnToggleItem("Überprüfung", reviewColumn); + HorizontalLayout toolbar = new HorizontalLayout(searchField, addMediaArticleButton, menuButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editMediaArticle(MediaArticle mediaArticle) { + if (mediaArticle == null) { + closeEditor(); + } else { + form.setMediaArticle(mediaArticle); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setMediaArticle(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addMediaArticle() { + grid.asSingleSelect().clear(); + editMediaArticle(new MediaArticle()); + } + + public void updateList() { + grid.setItems(service.findAllMediaArticles(searchField.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileForm.java b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileForm.java new file mode 100644 index 0000000..b1042fa --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileForm.java @@ -0,0 +1,113 @@ +package de.thpeetz.kontor.media.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.media.data.MediaFile; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MediaFileForm extends FormLayout { + + TextField id = new TextField("ID"); + TextField url = new TextField("URL"); + TextField title = new TextField("Title"); + TextField fileName = new TextField("Dateiname"); + TextField cloudLink = new TextField("Cloud Link"); + Checkbox review = new Checkbox("Review"); + Checkbox shouldDownload = new Checkbox("Download"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(MediaFile.class); + + public MediaFileForm() { + addClassName("mediafile-form"); + binder.bindInstanceFields(this); + id.setReadOnly(true); + add(id, 2); + add(url, 2); + add(title, 2); + add(fileName, 2); + add(cloudLink, 2); + add(review, shouldDownload, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(event -> save.setEnabled(event.getBinder().isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setMediaFile(MediaFile mediaFile) { + binder.setBean(mediaFile); + } + + @Getter + public abstract static class MediaFileFormEvent extends ComponentEvent { + private final MediaFile mediaFile; + + protected MediaFileFormEvent(MediaFileForm source, MediaFile mediaFile) { + super(source, false); + this.mediaFile = mediaFile; + } + + } + + public static class SaveEvent extends MediaFileFormEvent { + SaveEvent(MediaFileForm source, MediaFile mediaFile) { + super(source, mediaFile); + } + } + + public static class DeleteEvent extends MediaFileFormEvent { + DeleteEvent(MediaFileForm source, MediaFile mediaFile) { + super(source, mediaFile); + } + } + + public static class CloseEvent extends MediaFileFormEvent { + CloseEvent(MediaFileForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileView.java b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileView.java new file mode 100644 index 0000000..0784b8d --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaFileView.java @@ -0,0 +1,186 @@ +package de.thpeetz.kontor.media.views; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.contextmenu.ContextMenu; +import com.vaadin.flow.component.contextmenu.MenuItem; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.media.data.MediaFile; +import de.thpeetz.kontor.media.services.MediaFileService; +import jakarta.annotation.security.RolesAllowed; +import lombok.Getter; +import org.springframework.context.annotation.Scope; + +@SpringComponent +@Scope("prototype") +@RolesAllowed("MEDIA") +@Route(value = "media/mediafile", layout = MainLayout.class) +@PageTitle("MediaFile | Media | Kontor") +public class MediaFileView extends VerticalLayout { + + @Getter + Grid grid = new Grid<>(MediaFile.class, false); + Grid.Column idColumn = grid.addColumn(MediaFile::getId) + .setHeader("ID").setResizable(true).setSortable(true); + Grid.Column createdColumn = grid.addColumn(MediaFile::getCreatedDate) + .setHeader("Erstellt").setResizable(true).setSortable(true); + Grid.Column modifiedColumn = grid.addColumn(MediaFile::getLastModifiedDate) + .setHeader("Geändert").setResizable(true).setSortable(true); + Grid.Column urlColumn = grid.addColumn(MediaFile::getUrl) + .setHeader("URL").setResizable(true).setSortable(true); + Grid.Column titleColumn = grid.addColumn(MediaFile::getTitle) + .setHeader("Titel").setResizable(true).setSortable(true); + Grid.Column fileNameColumn = grid.addColumn(MediaFile::getFileName) + .setHeader("Dateiname").setResizable(true).setSortable(true); + Grid.Column cloudLinkColumn = grid.addColumn(MediaFile::getCloudLink) + .setHeader("Cloud Link").setResizable(true).setSortable(true); + Grid.Column reviewColumn = grid.addComponentColumn(mediafile -> createStatusIcon(mediafile.isReview())). + setHeader("Überprüfung").setWidth("6rem").setSortable(true); + Grid.Column shouldDownloadColumn = grid.addComponentColumn(mediafile -> createStatusIcon(mediafile.isShouldDownload())). + setHeader("Download?").setWidth("6rem").setSortable(true); + TextField searchField = new TextField(); + @Getter + MediaFileForm form; + MediaFileService service; + + private static class ColumnToggleContextMenu extends ContextMenu { + public ColumnToggleContextMenu(Component target) { + super(target); + setOpenOnClick(true); + } + + void addColumnToggleItem(String label, Grid.Column column) { + MenuItem menuItem = this.addItem(label, e -> { + column.setVisible(e.getSource().isChecked()); + }); + menuItem.setCheckable(true); + menuItem.setChecked(column.isVisible()); + menuItem.setKeepOpen(true); + } + } + + public MediaFileView(MediaFileService service) { + this.service = service; + addClassName("mediafile-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("mediafile-grid"); + grid.setSizeFull(); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + //idColumn.setVisible(false); + grid.asSingleSelect().addValueChangeListener(event -> editMediaFile(event.getValue())); + } + + private void configureForm() { + form = new MediaFileForm(); + form.setWidth("75em"); + form.setVisible(false); + form.addSaveListener(this::saveMediaFile); + form.addDeleteListener(this::deleteMediaFile); + form.addCloseListener(e -> closeEditor()); + } + + private void saveMediaFile(MediaFileForm.SaveEvent event) { + service.saveMediaFile(event.getMediaFile()); + updateList(); + closeEditor(); + } + + private Icon createStatusIcon(boolean status) { + Icon icon; + if (status) { + icon = VaadinIcon.CHECK.create(); + icon.getElement().getThemeList().add("badge success"); + } else { + icon = VaadinIcon.CLOSE_SMALL.create(); + icon.getElement().getThemeList().add("badge error"); + } + icon.getStyle().set("padding", "var(--lumo-space-xs"); + return icon; + } + + private void deleteMediaFile(MediaFileForm.DeleteEvent event) { + service.deleteMediaFile(event.getMediaFile()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + searchField.setPlaceholder("Search"); + searchField.setClearButtonVisible(true); + searchField.setPrefixComponent(new Icon(VaadinIcon.SEARCH)); + searchField.setValueChangeMode(ValueChangeMode.EAGER); + searchField.addValueChangeListener(e -> updateList()); + + Button addMediaFileButton = new Button("Add MediaFile"); + addMediaFileButton.addClickListener(click -> addMediaFile()); + + Button menuButton = new Button("Show/Hide Columns"); + menuButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + ColumnToggleContextMenu columnToggleContextMenu = new ColumnToggleContextMenu(menuButton); + columnToggleContextMenu.addColumnToggleItem("ID", idColumn); + columnToggleContextMenu.addColumnToggleItem("Erstellt", createdColumn); + columnToggleContextMenu.addColumnToggleItem("Geändert", modifiedColumn); + columnToggleContextMenu.addColumnToggleItem("URL", urlColumn); + columnToggleContextMenu.addColumnToggleItem("Titel", titleColumn); + columnToggleContextMenu.addColumnToggleItem("Dateiname", fileNameColumn); + columnToggleContextMenu.addColumnToggleItem("Cloud Link", cloudLinkColumn); + columnToggleContextMenu.addColumnToggleItem("Überprüfung", reviewColumn); + columnToggleContextMenu.addColumnToggleItem("Download?", shouldDownloadColumn); + HorizontalLayout toolbar = new HorizontalLayout(searchField, addMediaFileButton, menuButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editMediaFile(MediaFile mediaFile) { + if (mediaFile == null) { + closeEditor(); + } else { + form.setMediaFile(mediaFile); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setMediaFile(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addMediaFile() { + grid.asSingleSelect().clear(); + editMediaFile(new MediaFile()); + } + + public void updateList() { + grid.setItems(service.findAllMediaFiles(searchField.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoForm.java b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoForm.java new file mode 100644 index 0000000..dbc8d47 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoForm.java @@ -0,0 +1,113 @@ +package de.thpeetz.kontor.media.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import de.thpeetz.kontor.media.data.MediaVideo; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MediaVideoForm extends FormLayout { + + TextField url = new TextField("URL"); + TextField title = new TextField("Title"); + TextField fileName = new TextField("Dateiname"); + TextField cloudLink = new TextField("Cloud Link"); + Checkbox review = new Checkbox("Review"); + Checkbox shouldDownload = new Checkbox("Download"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(MediaVideo.class); + + public MediaVideoForm() { + addClassName("mediavideo-form"); + binder.bindInstanceFields(this); + add(url, 2); + add(title, 2); + add(fileName, 2); + add(cloudLink, 2); + add(review, shouldDownload, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(event -> { + save.setEnabled(event.getBinder().isValid()); + }); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setMediaVideo(MediaVideo mediaVideo) { + binder.setBean(mediaVideo); + } + + public abstract static class MediaVideoFormEvent extends ComponentEvent { + private MediaVideo mediaVideo; + + protected MediaVideoFormEvent(MediaVideoForm source, MediaVideo mediaVideo) { + super(source, false); + this.mediaVideo = mediaVideo; + } + + public MediaVideo getMediaVideo() { + return mediaVideo; + } + } + + public static class SaveEvent extends MediaVideoFormEvent { + SaveEvent(MediaVideoForm source, MediaVideo mediaVideo) { + super(source, mediaVideo); + } + } + + public static class DeleteEvent extends MediaVideoFormEvent { + DeleteEvent(MediaVideoForm source, MediaVideo mediaVideo) { + super(source, mediaVideo); + } + } + + public static class CloseEvent extends MediaVideoFormEvent { + CloseEvent(MediaVideoForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoView.java b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoView.java new file mode 100644 index 0000000..8b0c131 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/media/views/MediaVideoView.java @@ -0,0 +1,178 @@ +package de.thpeetz.kontor.media.views; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.contextmenu.ContextMenu; +import com.vaadin.flow.component.contextmenu.MenuItem; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.media.MediaConstants; +import de.thpeetz.kontor.media.data.MediaVideo; +import de.thpeetz.kontor.media.services.MediaVideoService; +import jakarta.annotation.security.PermitAll; +import lombok.Getter; +import org.springframework.context.annotation.Scope; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = MediaConstants.MEDIAVIDEO_ROUTE, layout = MainLayout.class) +@PageTitle("MediaVideo | Media | Kontor") +public class MediaVideoView extends VerticalLayout { + + @Getter + Grid grid = new Grid<>(MediaVideo.class, false); + Grid.Column idColumn = grid.addColumn(MediaVideo::getId).setHeader("ID").setResizable(true); + Grid.Column urlColumn = grid.addColumn(MediaVideo::getUrl).setHeader("URL").setResizable(true).setSortable(true); + Grid.Column titleColumn = grid.addColumn(MediaVideo::getTitle).setHeader("Titel").setResizable(true).setSortable(true); + Grid.Column fileNameColumn = grid.addColumn(MediaVideo::getFileName).setHeader("Dateiname").setResizable(true).setSortable(true); + Grid.Column cloudLinkColumn = grid.addColumn(MediaVideo::getCloudLink).setHeader("Cloud Link").setResizable(true).setSortable(true); + Grid.Column reviewColumn = grid.addComponentColumn(mediaVideo -> createStatusIcon(mediaVideo.isReview())) + .setHeader("Überprüfung") + .setWidth("6rem"); + Grid.Column shouldDownloadColumn = grid.addComponentColumn(mediaVideo -> createStatusIcon(mediaVideo.isShouldDownload())) + .setHeader("Download?") + .setWidth("6rem"); + TextField searchField = new TextField(); + @Getter + MediaVideoForm form; + MediaVideoService service; + + private static class ColumnToggleContextMenu extends ContextMenu { + public ColumnToggleContextMenu(Component target) { + super(target); + setOpenOnClick(true); + } + + void addColumnToggleItem(String label, Grid.Column column) { + MenuItem menuItem = this.addItem(label, e -> { + column.setVisible(e.getSource().isChecked()); + }); + menuItem.setCheckable(true); + menuItem.setChecked(column.isVisible()); + menuItem.setKeepOpen(true); + } + } + + public MediaVideoView(MediaVideoService service) { + this.service = service; + addClassName("mediavideo-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + private void configureGrid() { + grid.addClassName("mediavideo-grid"); + grid.setSizeFull(); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + idColumn.setVisible(false); + grid.asSingleSelect().addValueChangeListener(event -> editMediaVideo(event.getValue())); + } + + private void configureForm() { + form = new MediaVideoForm(); + form.setWidth("75em"); + form.setVisible(false); + form.addSaveListener(this::saveMediaVideo); + form.addDeleteListener(this::deleteMediaVideo); + form.addCloseListener(e -> closeEditor()); + } + + private void saveMediaVideo(MediaVideoForm.SaveEvent event) { + service.saveMediaVideo(event.getMediaVideo()); + updateList(); + closeEditor(); + } + + private Icon createStatusIcon(boolean status) { + Icon icon; + if (status) { + icon = VaadinIcon.CHECK.create(); + icon.getElement().getThemeList().add("badge success"); + } else { + icon = VaadinIcon.CLOSE_SMALL.create(); + icon.getElement().getThemeList().add("badge error"); + } + icon.getStyle().set("padding", "var(--lumo-space-xs"); + return icon; + } + + private void deleteMediaVideo(MediaVideoForm.DeleteEvent event) { + service.deleteMediaVideo(event.getMediaVideo()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + searchField.setPlaceholder("Search"); + searchField.setClearButtonVisible(true); + searchField.setPrefixComponent(new Icon(VaadinIcon.SEARCH)); + searchField.setValueChangeMode(ValueChangeMode.EAGER); + searchField.addValueChangeListener(e -> updateList()); + + Button addMediaVideoButton = new Button("Add MediaVideo"); + addMediaVideoButton.addClickListener(click -> addMediaVideo()); + + Button menuButton = new Button("Show/Hide Columns"); + menuButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + MediaVideoView.ColumnToggleContextMenu columnToggleContextMenu = new MediaVideoView.ColumnToggleContextMenu(menuButton); + columnToggleContextMenu.addColumnToggleItem("ID", idColumn); + columnToggleContextMenu.addColumnToggleItem("URL", urlColumn); + columnToggleContextMenu.addColumnToggleItem("Titel", titleColumn); + columnToggleContextMenu.addColumnToggleItem("Dateiname", fileNameColumn); + columnToggleContextMenu.addColumnToggleItem("Cloud Link", cloudLinkColumn); + columnToggleContextMenu.addColumnToggleItem("Überprüfung", reviewColumn); + columnToggleContextMenu.addColumnToggleItem("Download?", shouldDownloadColumn); + HorizontalLayout toolbar = new HorizontalLayout(searchField, addMediaVideoButton, menuButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editMediaVideo(MediaVideo mediaVideo) { + if (mediaVideo == null) { + closeEditor(); + } else { + form.setMediaVideo(mediaVideo); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setMediaVideo(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addMediaVideo() { + grid.asSingleSelect().clear(); + editMediaVideo(new MediaVideo()); + } + + public void updateList() { + grid.setItems(service.findAllMediaVideos(searchField.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/security/SecurityConfig.java b/springboot/src/main/java/de/thpeetz/kontor/security/SecurityConfig.java new file mode 100644 index 0000000..ae77838 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/security/SecurityConfig.java @@ -0,0 +1,59 @@ +package de.thpeetz.kontor.security; + +import com.vaadin.flow.spring.security.VaadinWebSecurity; + +import de.thpeetz.kontor.admin.services.KontorUserDetailsService; +import de.thpeetz.kontor.admin.views.LoginView; + +import java.util.Base64; + +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jose.jws.JwsAlgorithms; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +@EnableWebSecurity +@Configuration +public class SecurityConfig extends VaadinWebSecurity { + + public static final String LOGOUT_URL = "/"; + + @Value("${jwt.auth.secret}") + private String authSecret; + + @Autowired + private KontorUserDetailsService userDetailsService; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth.requestMatchers( + AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/images/*.png")).permitAll()); + super.configure(http); + setLoginView(http, LoginView.class); + setStatelessAuthentication(http, new SecretKeySpec(Base64.getDecoder().decode(authSecret), JwsAlgorithms.HS256), + "de.thpeetz.kontor"); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + final DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(encoder()); + return authProvider; + } + + @Bean + public PasswordEncoder encoder() { + return new BCryptPasswordEncoder(11); + } +} \ No newline at end of file diff --git a/springboot/src/main/java/de/thpeetz/kontor/security/SecurityService.java b/springboot/src/main/java/de/thpeetz/kontor/security/SecurityService.java new file mode 100644 index 0000000..4d17d31 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/security/SecurityService.java @@ -0,0 +1,105 @@ +package de.thpeetz.kontor.security; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.stereotype.Component; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.VaadinServletRequest; +import com.vaadin.flow.server.VaadinServletResponse; +import com.vaadin.flow.spring.security.AuthenticationContext; + +import de.thpeetz.kontor.admin.services.KontorUserDetailsService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Component +@Service +public class SecurityService { + + private static final String LOGOUT_SUCCESS_URL = "/"; + + private final AuthenticationContext authenticationContext; + + @Autowired + private KontorUserDetailsService userService; + + public SecurityService(AuthenticationContext authenticationContext) { + this.authenticationContext = authenticationContext; + } + + public Optional getAuthenticatedUser() { + String name = SecurityContextHolder.getContext().getAuthentication().getName(); + log.info("getAuthenticatedUser: {}", name); + + return Optional.ofNullable(userService.loadUserByUsername(name)); + } + + /*public static UserDetails getAuthenticatedUser() { + SecurityContext context = SecurityContextHolder.getContext(); + final Authentication authentication = context.getAuthentication(); + log.info("Authentication: {}", authentication); + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof UserDetails) { + return (UserDetails) authentication.getPrincipal(); + } + return null; + }*/ + + public void logout() { + log.info("logout"); + authenticationContext.logout(); + clearCookies(); + + UI.getCurrent().getPage().setLocation(LOGOUT_SUCCESS_URL); + SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler(); + logoutHandler.logout(VaadinServletRequest.getCurrent().getHttpServletRequest(), null, null); + clearCookies(); + } + + public boolean isLoggedIn() { + log.info("User {}", getAuthenticatedUser()); + return getAuthenticatedUser().isPresent(); + } + + private static final String JWT_HEADER_AND_PAYLOAD_COOKIE_NAME = "jwt.headerAndPayload"; + private static final String JWT_SIGNATURE_COOKIE_NAME = "jwt.signature"; + + private void clearCookies() { + log.info("clearCookies"); + clearCookie(JWT_HEADER_AND_PAYLOAD_COOKIE_NAME); + clearCookie(JWT_SIGNATURE_COOKIE_NAME); + } + + private void clearCookie(String cookieName) { + log.info("clearCookie: {}", cookieName); + HttpServletRequest request = VaadinServletRequest.getCurrent() + .getHttpServletRequest(); + HttpServletResponse response = VaadinServletResponse.getCurrent() + .getHttpServletResponse(); + + Cookie k = new Cookie(cookieName, null); + k.setPath(getRequestContextPath(request)); + k.setMaxAge(0); + k.setSecure(request.isSecure()); + k.setHttpOnly(false); + response.addCookie(k); + } + + private String getRequestContextPath(HttpServletRequest request) { + log.info("getRequestContextPath"); + final String contextPath = request.getContextPath(); + return "".equals(contextPath) ? "/" : contextPath; + } +} \ No newline at end of file diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/SetupModuleTysc.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/SetupModuleTysc.java new file mode 100644 index 0000000..bf6e1e4 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/SetupModuleTysc.java @@ -0,0 +1,519 @@ +package de.thpeetz.kontor.tysc; + +import de.thpeetz.kontor.tysc.data.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; + +import de.thpeetz.kontor.admin.services.ModuleService; +import de.thpeetz.kontor.tysc.data.FieldPosition; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SetupModuleTysc implements ApplicationListener { + + boolean alreadySetup = false; + + @Autowired + private SportRepository sportRepository; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private VendorRepository vendorRepository; + + @Autowired + private FieldPositionRepository fieldPositionRepository; + + @Autowired + private PlayerRepository playerRepository; + + @Autowired + private CardSetRepository cardSetRepository; + + @Autowired + private RoosterRepository roosterRepository; + + @Autowired + private CardRepository cardRepository; + + @Autowired + private ModuleService moduleService; + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (alreadySetup) { + log.info("SetupDataLoader(TYSC) already executed, skipping"); + return; + } + if (!moduleService.importData(TyscConstants.TYSC)) { + log.info("Module TradeYourSportsCards should not setup data"); + return; + } + log.info("Setp up TYSC data"); + Sport football = createSportIfNotFound("Football"); + Sport baseball = createSportIfNotFound("Baseball"); + Sport basketball = createSportIfNotFound("Basketball"); + Sport hockey = createSportIfNotFound("Hockey"); + createTeamIfNotFound(football, "Buffalo Bills", "Bills"); + Team colts = createTeamIfNotFound(football, "Indianapolis Colts", "Colts"); + createTeamIfNotFound(football, "Miami Dolphins", "Dolphins"); + Team patriots = createTeamIfNotFound(football, "New England Patriots", "Patriots"); + createTeamIfNotFound(football, "New York Jets", "Jets"); + Team ravens = createTeamIfNotFound(football, "Baltimore Ravens", "Ravens"); + createTeamIfNotFound(football, "Cincinnati Bengals", "Bengals"); + Team browns = createTeamIfNotFound(football, "Cleveland Browns", "Browns"); + createTeamIfNotFound(football, "Jacksonville Jaguars", "Jaguars"); + Team steelers = createTeamIfNotFound(football, "Pittsburgh Steelers", "Steelers"); + createTeamIfNotFound(football, "Tennessee Titans", "Titans"); + createTeamIfNotFound(football, "Denver Broncos", "Broncos"); + createTeamIfNotFound(football, "Kansas City Chiefs", "Chiefs"); + createTeamIfNotFound(football, "Oakland Raiders", "Raiders"); + createTeamIfNotFound(football, "San Diego Chargers", "Chargers"); + createTeamIfNotFound(football, "Seattle Seahawks", "Seahawks"); + createTeamIfNotFound(football, "Arizona Cardinals", "Cardinals"); + createTeamIfNotFound(football, "Dallas Cowboys", "Cowboys"); + createTeamIfNotFound(football, "New York Giants", "Giants"); + createTeamIfNotFound(football, "Philadelphia Eagles", "Eagles"); + Team redskins = createTeamIfNotFound(football, "Washington Redskins", "Redskins"); + createTeamIfNotFound(football, "Chicago Bears", "Bears"); + createTeamIfNotFound(football, "Detroit Lions", "Lions"); + createTeamIfNotFound(football, "Green Bay Packers", "Packers"); + createTeamIfNotFound(football, "Minnesota Vikings", "Vikings"); + createTeamIfNotFound(football, "Tampa Bay Buccaneers", "Buccaneers"); + createTeamIfNotFound(football, "Atlanta Falcons", "Falcons"); + createTeamIfNotFound(football, "Carolina Panthers", "Panthers"); + createTeamIfNotFound(football, "New Orleans Saints", "Saints"); + createTeamIfNotFound(football, "St.Louis Rams", "Rams"); + createTeamIfNotFound(football, "San Francisco 49ers", "49ers"); + createTeamIfNotFound(football, "Houston Texans", "Texans"); + createTeamIfNotFound(football, "Houston Oilers", "Oilers"); + createTeamIfNotFound(baseball, "Baltimore Orioles", "Orioles"); + createTeamIfNotFound(baseball, "Boston Red Sox", "Red Sox"); + createTeamIfNotFound(baseball, "New York Yankees", "Yankees"); + createTeamIfNotFound(baseball, "Tampa Bay Devil Rays", "Devil Rays"); + createTeamIfNotFound(baseball, "Toronto Blue Jays", "Blue Jays"); + createTeamIfNotFound(baseball, "Chicago White Sox", "White Sox"); + createTeamIfNotFound(baseball, "Cleveland Indians", "Indians"); + createTeamIfNotFound(baseball, "Detroit Tigers", "Tigers"); + createTeamIfNotFound(baseball, "Kansas City Royals", "Royals"); + createTeamIfNotFound(baseball, "Minnesota Twins", "Twins"); + createTeamIfNotFound(baseball, "Anaheim Angels", "Angels"); + createTeamIfNotFound(baseball, "Oakland Athletics", "Athletics"); + createTeamIfNotFound(baseball, "Seattle Mariners", "Mariners"); + createTeamIfNotFound(baseball, "Texas Rangers", "Rangers"); + createTeamIfNotFound(baseball, "Atlanta Braves", "Braves"); + createTeamIfNotFound(baseball, "Florida Marlins", "Marlins"); + createTeamIfNotFound(baseball, "Montreal Expos", "Expos"); + createTeamIfNotFound(baseball, "New York Mets", "Mets"); + createTeamIfNotFound(baseball, "Philadelphia Phillies", "Phillies"); + createTeamIfNotFound(baseball, "Chicago Cubs", "Cubs"); + createTeamIfNotFound(baseball, "Cincinnati Reds", "Reds"); + createTeamIfNotFound(baseball, "Houston Astros", "Astros"); + createTeamIfNotFound(baseball, "Milwaukee Brewers", "Brewers"); + createTeamIfNotFound(baseball, "Pittsburgh Pirates", "Pirates"); + createTeamIfNotFound(baseball, "St.Louis Cardinals", "Cardinals"); + createTeamIfNotFound(baseball, "Arizona Diamondbacks", "Diamondbacks"); + createTeamIfNotFound(baseball, "Colorado Rockies", "Rockies"); + createTeamIfNotFound(baseball, "Los Angeles Dodgers", "Dodgers"); + createTeamIfNotFound(baseball, "San Diego Padres", "Padres"); + createTeamIfNotFound(baseball, "San Francisco Giants", "Giants"); + createTeamIfNotFound(basketball, "Boston Celtics", "Celtics"); + createTeamIfNotFound(basketball, "Miami Heat", "Heat"); + createTeamIfNotFound(basketball, "New Jersey Nets", "Mets"); + createTeamIfNotFound(basketball, "New York Knicks", "Knicks"); + createTeamIfNotFound(basketball, "Orlando Magic", "Magic"); + createTeamIfNotFound(basketball, "Philadelphia 76ers", "76ers"); + createTeamIfNotFound(basketball, "Washington Wizards", "Wizards"); + createTeamIfNotFound(basketball, "Atlanta Hawks", "Hawks"); + createTeamIfNotFound(basketball, "Charlotte Hornets", "Hornets"); + createTeamIfNotFound(basketball, "Chicago Bulls", "Bulls"); + createTeamIfNotFound(basketball, "Cleveland Cavaliers", "Cavaliers"); + createTeamIfNotFound(basketball, "Detroit Pistons", "Pistons"); + createTeamIfNotFound(basketball, "Indiana Pacers", "Pacers"); + createTeamIfNotFound(basketball, "Milwaukee Bucks", "Bucks"); + createTeamIfNotFound(basketball, "Toronto Raptors", "Raptors"); + createTeamIfNotFound(basketball, "Dallas Mavericks", "Mavericks"); + createTeamIfNotFound(basketball, "Denver Nuggets", "Nuggets"); + createTeamIfNotFound(basketball, "Houston Rockets", "Rockets"); + createTeamIfNotFound(basketball, "Minnesota Timberwolves", "Timberwolves"); + createTeamIfNotFound(basketball, "San Antonio Spurs", "Spurs"); + createTeamIfNotFound(basketball, "Utah Jazz", "Jazz"); + createTeamIfNotFound(basketball, "Vancouver Grizzlies", "Grizzlies"); + createTeamIfNotFound(basketball, "Golden State Warriors", "Warriors"); + createTeamIfNotFound(basketball, "Los Angeles Clippers", "Clippers"); + createTeamIfNotFound(basketball, "Los Angeles Lakers", "Lakers"); + createTeamIfNotFound(basketball, "Phoenix Suns", "Suns"); + createTeamIfNotFound(basketball, "Portland Trail Blazers", "Blazers"); + createTeamIfNotFound(basketball, "Sacramento Kings", "Kings"); + createTeamIfNotFound(basketball, "Seattle SuperSonics", "SuperSonics"); + createTeamIfNotFound(hockey, "Boston Bruins", "Bruins"); + createTeamIfNotFound(hockey, "Buffalo Sabres", "Sabres"); + createTeamIfNotFound(hockey, "Montreal Canadiens", "Canadiens"); + createTeamIfNotFound(hockey, "Ottawa Senators", "Senators"); + createTeamIfNotFound(hockey, "Toronto Maple Leafs", "Maple Leafs"); + createTeamIfNotFound(hockey, "New Jersey Devils", "Devils"); + createTeamIfNotFound(hockey, "New York Islanders", "Islanders"); + createTeamIfNotFound(hockey, "New York Rangers", "Rangers"); + createTeamIfNotFound(hockey, "Philadelphia Flyers", "Flyers"); + createTeamIfNotFound(hockey, "Pittsburgh Penguins", "Penguins"); + createTeamIfNotFound(hockey, "Atlanta Trashers", "Trashers"); + createTeamIfNotFound(hockey, "Carolina Hurricanes", "Hurricanes"); + createTeamIfNotFound(hockey, "Florida Panthers", "Panthers"); + createTeamIfNotFound(hockey, "Tampa Bay Lightnings", "Lightnings"); + createTeamIfNotFound(hockey, "Washington Capitals", "Capitals"); + createTeamIfNotFound(hockey, "Chicago Blackhawks", "Blackhawks"); + createTeamIfNotFound(hockey, "Columbus Blue Jackets", "Blue Jackets"); + createTeamIfNotFound(hockey, "Detroit Red Wings", "Red Wings"); + createTeamIfNotFound(hockey, "Nashville Predators", "Predators"); + createTeamIfNotFound(hockey, "St.Louis Blues", "Blues"); + createTeamIfNotFound(hockey, "Calgary Flames", "Flames"); + createTeamIfNotFound(hockey, "Colorado Avalanche", "Avalanche"); + createTeamIfNotFound(hockey, "Edmonton Oilers", "Oilers"); + createTeamIfNotFound(hockey, "Minnesota Wild", "Wild"); + createTeamIfNotFound(hockey, "Vancouver Canucks", "Canucks"); + createTeamIfNotFound(hockey, "Anaheim Mighty Ducks", "Mighty Ducks"); + createTeamIfNotFound(hockey, "Dallas Stars", "Stars"); + createTeamIfNotFound(hockey, "Los Angeles Kings", "Kings"); + createTeamIfNotFound(hockey, "Phoenix Coyotes", "Coyotes"); + createTeamIfNotFound(hockey, "San Jose Sharks", "Sharks"); + FieldPosition qb = createPosition(football, "QB", "Quarterback"); + FieldPosition rb = createPosition(football, "RB", "Running Back"); + FieldPosition wr = createPosition(football, "WR", "Wide Receiver"); + FieldPosition te = createPosition(football, "TE", "Tight End"); + FieldPosition fb = createPosition(football, "FB", "Fullback"); + createPosition(football, "OL", "Offensive Line"); + createPosition(football, "DL", "Defensive Line"); + FieldPosition lb = createPosition(football, "LB", "Linebacker"); + createPosition(football, "DB", "Defensive Back"); + createPosition(football, "DE", "Defensive End"); + createPosition(football, "K", "Kicker"); + createPosition(football, "P", "Punter"); + createPosition(football, "S", "Safety"); + createPosition(football, "KR", "Kick Returner"); + createPosition(football, "PR", "Punt Returner"); + createPosition(football, "LS", "Long Snapper"); + createPosition(football, "LG", "Left Guard"); + createPosition(football, "RG", "Right Guard"); + createPosition(football, "OF", "Offensive Tackle"); + createPosition(football, "DB", "Defensive Back"); + createPosition(football, "CB", "Cornerback"); + createPosition(football, "DT", "Defensive Tackle"); + createPosition(football, "NT", "Nose Tackle"); + createPosition(football, "OLB", "Outside Linebacker"); + createPosition(football, "ILB", "Inside Linebacker"); + createPosition(football, "SS", "Strong Safety"); + createPosition(baseball, "P", "Pitcher"); + createPosition(baseball, "C", "Catcher"); + createPosition(baseball, "1B", "First Base"); + createPosition(baseball, "2B", "Second Base"); + createPosition(baseball, "3B", "Third Base"); + createPosition(baseball, "SS", "Shortstop"); + createPosition(baseball, "LF", "Left Field"); + createPosition(baseball, "CF", "Center Field"); + createPosition(baseball, "RF", "Right Field"); + createPosition(basketball, "PG", "Point Guard"); + createPosition(basketball, "SG", "Shooting Guard"); + createPosition(basketball, "SF", "Small Forward"); + createPosition(basketball, "PF", "Power Forward"); + createPosition(basketball, "C", "Center"); + createPosition(hockey, "G", "Goalie"); + createPosition(hockey, "D", "Defense"); + createPosition(hockey, "LW", "Left Wing"); + createPosition(hockey, "RW", "Right Wing"); + createPosition(hockey, "C", "Center"); + Vendor pacific = createVendorIfNotFound("Pacific"); + Vendor fleer = createVendorIfNotFound("Fleer"); + Vendor bowman = createVendorIfNotFound("Bowman"); + Vendor leaf = createVendorIfNotFound("Leaf"); + Vendor upperdeck = createVendorIfNotFound("Upper Deck"); + createVendorIfNotFound("Topps"); + createVendorIfNotFound("Donruss"); + createVendorIfNotFound("Score"); + createVendorIfNotFound("Flair"); + createCardSetIfNotFound("Mystique Big Buzz", fleer, false, true); + createCardSetIfNotFound("Mystique Gold", fleer, true, false); + createCardSetIfNotFound("Pacific Copper", pacific, true, false); + createCardSetIfNotFound("Pacific Gold", pacific, true, false); + CardSet pacificbase = createCardSetIfNotFound(pacific.getName(), pacific, false, false); + createCardSetIfNotFound(fleer.getName(), fleer, false, false); + createCardSetIfNotFound(bowman.getName(), bowman, false, false); + createCardSetIfNotFound(leaf.getName(), leaf, false, false); + createCardSetIfNotFound("Ultra", fleer, false, false); + createCardSetIfNotFound("Mystique", fleer, false, false); + createCardSetIfNotFound("Finest Hour", pacific, false, false); + createCardSetIfNotFound("SP", upperdeck, false, false); + createCardSetIfNotFound("SPX", upperdeck, false, false); + createCardSetIfNotFound("SP Authentic", upperdeck, false, false); + createCardSetIfNotFound("Black Diamond", upperdeck, false, false); + Player jeromepathon = createPlayerIfNotFound("Jerome", "Pathon"); + Player bruschi = createPlayerIfNotFound("Tedy", "Bruschi"); + Player couch = createPlayerIfNotFound("Tim", "Couch"); + Player shea = createPlayerIfNotFound("Aaron", "Shea"); + Player jamallewis = createPlayerIfNotFound("Jamal", "Lewis"); + Player jermainelewis = createPlayerIfNotFound("Jermaine", "Lewis"); + Player tonybanks = createPlayerIfNotFound("Tony", "Banks"); + Player chrisfuamatu = createPlayerIfNotFound("Chris", "Fuamatu-Ma'afala"); + Player jeromebettis = createPlayerIfNotFound("Jerome", "Bettis"); + Player kordellstewart = createPlayerIfNotFound("Kordell", "Stewart"); + createPlayerIfNotFound("Warren", "Moon"); + createPlayerIfNotFound("Kevin", "Lockett"); + createPlayerIfNotFound("Rich", "Gannon"); + createPlayerIfNotFound("James", "Jett"); + createPlayerIfNotFound("Mack", "Strong"); + createPlayerIfNotFound("Brock", "Huard"); + createPlayerIfNotFound("Ricky", "Watters"); + createPlayerIfNotFound("Troy", "Aikman"); + createPlayerIfNotFound("David", "LaFleur"); + createPlayerIfNotFound("Chris", "Brazzell"); + createPlayerIfNotFound("Ron", "Dayne"); + createPlayerIfNotFound("Na", "Brown"); + createPlayerIfNotFound("Torrance", "Small"); + createPlayerIfNotFound("Chad", "Lewis"); + createPlayerIfNotFound("Adrian", "Murrell"); + createPlayerIfNotFound("Chris", "Chandler"); + createPlayerIfNotFound("Danny", "Kanell"); + createPlayerIfNotFound("Ricky", "Williams"); + createPlayerIfNotFound("Jeff", "Garcia"); + createPlayerIfNotFound("Tai", "Streets"); + createPlayerIfNotFound("Charlie", "Garner"); + createPlayerIfNotFound("Drew", "Bledsoe"); + createPlayerIfNotFound("Antowain", "Smith"); + createPlayerIfNotFound("Terry", "Glenn"); + createPlayerIfNotFound("Jerry", "Rice"); + createPlayerIfNotFound("Terrell", "Owens"); + createPlayerIfNotFound("Isaac", "Bruce"); + createPlayerIfNotFound("Trung", "Canidate"); + Rooster pathoncoltswr2001 = createRoosterIfNotFound(jeromepathon, colts, wr, 2001); + Rooster bruschipatriotslb2001 = createRoosterIfNotFound(bruschi, patriots, lb, 2001); + Rooster couchbrownsqb2001 = createRoosterIfNotFound(couch, browns, qb, 2001); + Rooster sheabrownste2001 = createRoosterIfNotFound(shea, browns, te, 2001); + Rooster jamallewisravensrb2001 = createRoosterIfNotFound(jamallewis, ravens, rb, 2001); + Rooster jermainelewisravenswr2001 = createRoosterIfNotFound(jermainelewis, ravens, wr, 2001); + Rooster tonybanksravensqb2001 = createRoosterIfNotFound(tonybanks, ravens, qb, 2001); + createRoosterIfNotFound(tonybanks, redskins, qb, 2002); + Rooster chrisfuamatusteelersfb2001 = createRoosterIfNotFound(chrisfuamatu, steelers, fb, 2001); + Rooster jeromebettissteelersrb2001 = createRoosterIfNotFound(jeromebettis, steelers, rb, 2001); + Rooster kordellstewartsteelersqb2001 = createRoosterIfNotFound(kordellstewart, steelers, qb, 2001); + createCardIfNotFound(185, 2001, pacific, pacificbase, pathoncoltswr2001); + createCardIfNotFound(250, 2001, pacific, pacificbase, bruschipatriotslb2001); + createCardIfNotFound(103, 2001, pacific, pacificbase, couchbrownsqb2001); + createCardIfNotFound(112, 2001, pacific, pacificbase, sheabrownste2001); + createCardIfNotFound(37, 2001, pacific, pacificbase, jamallewisravensrb2001); + createCardIfNotFound(38, 2001, pacific, pacificbase, jermainelewisravenswr2001); + createCardIfNotFound(31, 2001, pacific, pacificbase, tonybanksravensqb2001); + createCardIfNotFound(338, 2001, pacific, pacificbase, chrisfuamatusteelersfb2001); + createCardIfNotFound(335, 2001, pacific, pacificbase, jeromebettissteelersrb2001); + createCardIfNotFound(345, 2001, pacific, pacificbase, kordellstewartsteelersqb2001); + /* + * INSERT INTO `spieler` (`ID`,`name`) VALUES + * (11,'Moon, Warren'), + * (12,'Lockett, Kevin'), + * (13,'Gannon, Rich'), + * (14,'Jett, James'), + * (15,'Strong, Mack'), + * (16,'Huard, Brock'), + * (17,'Watters, Ricky'), + * (18,'Aikman, Troy'), + * (19,'LaFleur, David'), + * (20,'Brazzell, Chris'), + * (21,'Dayne, Ron'), + * (22,'Brown, Na'), + * (23,'Small, Torrance'), + * (24,'Lewis, Chad'), + * (25,'Murrell, Adrian'), + * (26,'Smith, Maurice'), + * (27,'Chandler, Chris'), + * (28,'Kanell, Danny') + * ,(29,'Williams, Ricky'), + * (30,'Garcia, Jeff'), + * (31,'Streets, Tai'), + * (32,'Garner, Charlie'), + * (33,'Rice, Jerry'), + * (34,'Owens, Terrell'), + * (35,'Bruce, Isaac'), + * (36,'Canidate, Trung'); + * + * INSERT INTO `team` (`ID`,`sportart_id`,`name`,`short`) VALUES + * (1,1,'Buffalo Bills','Bills'), + * (2,1,'Indianapolis Colts','Colts'), + * (3,1,'Miami Dolphins','Dolphins'), + * (4,1,'New England Patriots','Patriots'), + * (5,1,'New York Jets','Jets'), + * (6,1,'Baltimore Ravens','Ravens'), + * (7,1,'Cincinnati Bengals','Bengals'), + * (8,1,'Cleveland Browns','Browns'), + * (9,1,'Jacksonville Jaguars','Jaguars'), + * (10,1,'Pittsburgh Steelers','Steelers'), + * (11,1,'Tennessee Titans','Titans'), + * (12,1,'Denver Broncos','Broncos'), + * (13,1,'Kansas City Chiefs','Chiefs'), + * (14,1,'Oakland Raiders','Raiders'), + * (15,1,'San Diego Chargers','Chargers'), + * (16,1,'Seattle Seahawks','Seahawks'), + * (17,1,'Arizona Cardinals','Cardinals'), + * (18,1,'Dallas Cowboys','Cowboys'), + * (19,1,'New York Giants','Giants'), + * (20,1,'Philadelphia Eagles','Eagles'), + * (21,1,'Washington Redskins','Redskins'), + * (22,1,'Chicago Bears','Bears'), + * (23,1,'Detroit Lions','Lions'), + * (24,1,'Green Bay Packers','Packers'), + * (25,1,'Minnesota Vikings','Vikings'), + * (26,1,'Tampa Bay Buccaneers','Buccaneers'), + * (27,1,'Atlanta Falcons','Falcons'), + * (28,1,'Carolina Panthers','Panthers'); + * (29,1,'New Orleans Saints','Saints'), + * (30,1,'St.Louis Rams','Rams'), + * (31,1,'San Francisco 49ers','49ers'), + * + * INSERT INTO `karte` + * (`ID`,`spieler_id`,`team_id`,`hersteller_id`,`serie_id`,`parallel_id`,` + * inserts_id`,`rookie`,`jahr`,`nummer`) VALUES + * (11,11,13,1,1,0,0,'N',2001,213), + * (12,12,13,1,1,0,0,'N',2001,212), + * (13,13,14,1,1,0,0,'N',2001,311), + * (14,14,14,1,1,0,0,'N',2001,312), + * (15,15,16,1,1,0,0,'N',2001,403), + * (16,16,16,1,1,0,0,'N',2001,397), + * (17,17,16,1,1,0,0,'N',2001,404), + * (18,18,18,1,1,0,0,'N',2001,116), + * (19,19,18,1,1,0,0,'N',2001,122), + * (20,20,18,1,1,0,0,'N',2001,117), + * (21,21,19,1,1,0,0,'N',2001,281), + * (22,22,19,1,1,0,0,'N',2001,321), + * (23,23,20,1,1,0,0,'N',2001,331), + * (24,24,20,1,1,0,0,'N',2001,324), + * (25,25,21,1,1,0,0,'N',2001,445), + * (26,26,27,1,1,0,0,'N',2001,28), + * (27,27,27,1,1,0,0,'N',2001,17), + * (28,28,27,1,1,0,0,'N',2001,23), + * (29,29,29,1,1,0,0,'N',2001,273); + * (30,30,31,1,1,0,0,'N',2001,380), + * (31,31,31,1,1,0,0,'N',2001,390), + * (32,32,31,1,1,0,0,'N',2001,381), + * (33,33,31,1,1,0,0,'N',2001,387), + * (34,34,31,1,1,0,0,'N',2001,386), + * (35,35,30,1,1,0,0,'N',2001,349), + * (36,36,30,1,1,0,0,'N',2001,350), + * (37,37,44,5,8,0,0,'N',1994,106); + */ + + alreadySetup = true; + moduleService.setDataImported(TyscConstants.TYSC); + } + + private Sport createSportIfNotFound(String sport) { + log.info("createSportIfNotFound: {}", sport); + Sport sportEntity = sportRepository.findByName(sport); + if (sportEntity == null) { + log.info("Sport {} not found, will create it", sport); + sportEntity = new Sport(); + sportEntity.setName(sport); + sportRepository.save(sportEntity); + } + return sportEntity; + } + + private Team createTeamIfNotFound(Sport football, String name, String shortName) { + log.info("createTeamIfNotFound: {}", name); + Team team = teamRepository.findByName(name); + if (team == null) { + log.info("Team {} not found, will create it", name); + team = new Team(); + team.setName(name); + team.setShortName(shortName); + team.setSport(football); + teamRepository.save(team); + } + return team; + } + + private FieldPosition createPosition(Sport sport, String shortName, String name) { + log.info("createPosition: {} for Sport {}", name, sport.getName()); + FieldPosition fieldPosition = fieldPositionRepository.findByShortNameAndSport(shortName, sport); + if (fieldPosition == null) { + log.info("Position {} not found, will create it", name); + fieldPosition = new FieldPosition(); + fieldPosition.setShortName(shortName); + fieldPosition.setName(name); + fieldPosition.setSport(sport); + fieldPositionRepository.save(fieldPosition); + } + return fieldPosition; + } + + private Vendor createVendorIfNotFound(String name) { + log.info("createVendorIfNotFound: {}", name); + Vendor vendor = vendorRepository.findByName(name); + if (vendor == null) { + log.info("Vendor {} not found, will create it", name); + vendor = new Vendor(); + vendor.setName(name); + vendorRepository.save(vendor); + } + return vendor; + } + + private Player createPlayerIfNotFound(String firstName, String lastName) { + log.info("createPlayerIfNotFound: {} {}", firstName, lastName); + Player player = playerRepository.findByFirstNameAndLastName(firstName, lastName); + if (player == null) { + log.info("Player {} {} not found, will create it", firstName, lastName); + player = new Player(); + player.setFirstName(firstName); + player.setLastName(lastName); + playerRepository.save(player); + } + return player; + } + + private CardSet createCardSetIfNotFound(String setname, Vendor vendor, boolean parallelSet, boolean insertSet) { + log.info("createCardSetIfNotFound: {} {}", setname, vendor.getName()); + CardSet cardSet = cardSetRepository.findByNameAndVendor(setname, vendor); + if (cardSet == null) { + log.info("CardType {} not found, will create it", cardSet); + cardSet = new CardSet(); + cardSet.setName(setname); + cardSet.setVendor(vendor); + cardSet.setParallelSet(parallelSet); + cardSet.setInsertSet(insertSet); + cardSetRepository.save(cardSet); + } + return cardSet; + } + + private Rooster createRoosterIfNotFound(Player player, Team team, FieldPosition fieldPosition, int year) { + log.info("createRoosterIfNotFound; {} {} {} {}", player.getFirstName(), player.getLastName(), team.getName(), + year); + Rooster rooster = roosterRepository.findByReferences(player, team, fieldPosition, year); + if (rooster == null) { + log.info("Rooster {} not found, will create it", player); + rooster = new Rooster(); + rooster.setPlayer(player); + rooster.setTeam(team); + rooster.setPosition(fieldPosition); + rooster.setYear(year); + roosterRepository.save(rooster); + } + return rooster; + } + + private void createCardIfNotFound(int cardNumber, int year, Vendor vendor, CardSet cardset, Rooster rooster) { + log.info("createCardIfNotFound: vendor={} cardset={} rooster={} cardNumber={} year={}", vendor, cardset, + rooster, cardNumber, year); + Card card = cardRepository.search(vendor, cardset, rooster, cardNumber, year); + if (card == null) { + card = new Card(); + card.setVendor(vendor); + card.setCardSet(cardset); + card.setRooster(rooster); + card.setCardNumber(cardNumber); + card.setYear(year); + cardRepository.save(card); + } + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/TyscConstants.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/TyscConstants.java new file mode 100644 index 0000000..cc41c93 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/TyscConstants.java @@ -0,0 +1,88 @@ +package de.thpeetz.kontor.tysc; + +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.sidenav.SideNavItem; +import com.vaadin.flow.router.RouterLink; + +import de.thpeetz.kontor.tysc.views.CardSetView; +import de.thpeetz.kontor.tysc.views.CardView; +import de.thpeetz.kontor.tysc.views.PlayerView; +import de.thpeetz.kontor.tysc.views.PositionView; +import de.thpeetz.kontor.tysc.views.RoosterView; +import de.thpeetz.kontor.tysc.views.SportView; +import de.thpeetz.kontor.tysc.views.TeamView; +import de.thpeetz.kontor.tysc.views.VendorView; + +/** + * The {@code TyscConstants} class contains constant values related to tysc. + */ +public class TyscConstants { + + public static final String TYSC = "TradeYourSportsCards"; + public static final String TYSC_ROLE = "ROLE_TYSC"; + public static final String VENDOR = "Vendor"; + public static final String VENDOR_ROUTE = "tysc/vendor"; + public static final String CARDSET = "Card Set"; + public static final String CARDSET_ROUTE = "tysc/cardset"; + public static final String CARD = "Card"; + public static final String CARD_ROUTE = "tysc/card"; + public static final String SPORT = "Sport"; + public static final String SPORT_ROUTE = "tysc/sport"; + public static final String TEAM = "Team"; + public static final String TEAM_ROUTE = "tysc/team"; + public static final String POSITION = "Position"; + public static final String POSITION_ROUTE = "tysc/position"; + public static final String PLAYER = "Player"; + public static final String PLAYER_ROUTE = "tysc/player"; + public static final String ROOSTER = "Rooster"; + public static final String ROOSTER_ROUTE = "tysc/rooster"; + + public static RouterLink getSportLink() { + return new RouterLink(SPORT, SportView.class); + } + + public static RouterLink getTeamLink() { + return new RouterLink(TEAM, TeamView.class); + } + + public static RouterLink getPlayerLink() { + return new RouterLink(PLAYER, PlayerView.class); + } + + public static RouterLink getPositionLink() { + return new RouterLink(POSITION, PositionView.class); + } + + public static RouterLink getRoosterLink() { + return new RouterLink(ROOSTER, RoosterView.class); + } + + public static RouterLink getVendorLink() { + return new RouterLink(VENDOR, VendorView.class); + } + + public static RouterLink getCardSetLink() { + return new RouterLink(CARDSET, CardSetView.class); + } + + public static RouterLink getCardLink() { + return new RouterLink(CARD, CardView.class); + } + + public static SideNavItem getTyscNavigation() { + SideNavItem tysc = new SideNavItem(TYSC, VENDOR_ROUTE, VaadinIcon.ARCHIVE.create()); + tysc.addItem(new SideNavItem(SPORT, SportView.class)); + tysc.addItem(new SideNavItem(TEAM, TeamView.class)); + tysc.addItem(new SideNavItem(PLAYER, PlayerView.class)); + tysc.addItem(new SideNavItem(POSITION, PositionView.class)); + tysc.addItem(new SideNavItem(ROOSTER, RoosterView.class)); + tysc.addItem(new SideNavItem(CARDSET, CardSetView.class)); + tysc.addItem(new SideNavItem(CARD, CardView.class)); + tysc.addItem(new SideNavItem(VENDOR, VendorView.class)); + return tysc; + } + + private TyscConstants() { + // private constructor to hide the implicit public one + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Card.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Card.java new file mode 100644 index 0000000..90ec739 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Card.java @@ -0,0 +1,49 @@ +package de.thpeetz.kontor.tysc.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = {@Index(columnList = "cardNumber, year")}, + uniqueConstraints = {@UniqueConstraint(columnNames = { "cardNumber", "year", "vendor_id", "cardSet_id" })} +) +public class Card extends AbstractEntity { + + private int cardNumber; + + private int year; + + @ManyToOne + @JoinColumn(name = "vendor_id") + @NotNull + @JsonIgnoreProperties({ "cards" }) + private Vendor vendor; + + @ManyToOne + @JoinColumn(name = "cardSet_id") + @NotNull + @JsonIgnoreProperties({ "cards" }) + private CardSet cardSet; + + @ManyToOne + @JoinColumn(name = "rooster_id") + @NotNull + @JsonIgnoreProperties({ "cards" }) + private Rooster rooster; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardRepository.java new file mode 100644 index 0000000..c894b28 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CardRepository extends JpaRepository { + + @Query("SELECT c FROM Card c WHERE c.vendor = ?1 AND c.cardSet = ?2 and c.rooster = ?3 and c.cardNumber = ?4 and c.year = ?5") + Card search(Vendor vendor, CardSet cardset, Rooster rooster, int cardNumber, int year); + + @Query("select c from Card c " + + "where str(c.cardNumber) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSet.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSet.java new file mode 100644 index 0000000..c72188c --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSet.java @@ -0,0 +1,41 @@ +package de.thpeetz.kontor.tysc.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = {@Index(columnList = "name, vendor_id")}, + uniqueConstraints = {@UniqueConstraint(columnNames = { "name", "vendor_id" })} +) +public class CardSet extends AbstractEntity { + + @NotEmpty + private String name; + + @ManyToOne() + @JoinColumn(name = "vendor_id") + @NotNull + @JsonIgnoreProperties({ "cardSets" }) + private Vendor vendor; + + private boolean parallelSet = false; + + private boolean insertSet = false; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSetRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSetRepository.java new file mode 100644 index 0000000..237953e --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/CardSetRepository.java @@ -0,0 +1,18 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CardSetRepository extends JpaRepository { + + List findByName(String name); + + CardSet findByNameAndVendor(String name, Vendor vendor); + + @Query("select c from CardSet c " + + "where lower(c.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPosition.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPosition.java new file mode 100644 index 0000000..a806e70 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPosition.java @@ -0,0 +1,52 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = {@Index(columnList = "name, sport_id")}, + uniqueConstraints = { + @UniqueConstraint(columnNames = { "name", "sport_id" }), + @UniqueConstraint(columnNames = { "shortName", "sport_id" }) + } +) +public class FieldPosition extends AbstractEntity { + + @NotEmpty + private String name; + + @NotEmpty + private String shortName; + + @ManyToOne + @JoinColumn(name = "sport_id") + @NotNull + @JsonIgnoreProperties({ "positions" }) + private Sport sport; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "position") + @Nullable + private List roosters; +} \ No newline at end of file diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPositionRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPositionRepository.java new file mode 100644 index 0000000..0f649cc --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/FieldPositionRepository.java @@ -0,0 +1,22 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface FieldPositionRepository extends JpaRepository { + + @Query("select p from FieldPosition p " + + "where lower(p.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + List findBySport(Sport sport); + + FieldPosition findByShortName(String searchTerm); + + List findByShortNameIgnoreCase(String shortName); + + FieldPosition findByShortNameAndSport(String shortName, Sport sport); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Player.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Player.java new file mode 100644 index 0000000..6517959 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Player.java @@ -0,0 +1,49 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = { + @Index(columnList = "firstName, lastName"), + @Index(columnList = "lastName, firstName") +}, uniqueConstraints = { + @UniqueConstraint(columnNames = { "firstName", "lastName" }) +}) +public class Player extends AbstractEntity { + + @NotNull + private String firstName; + + @NotNull + private String lastName; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "player") + @Nullable + private List roosters; + + public String getFullName() { + StringBuilder fullName = new StringBuilder(); + fullName.append(this.lastName); + fullName.append(", "); + fullName.append(this.firstName); + return fullName.toString(); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/PlayerRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/PlayerRepository.java new file mode 100644 index 0000000..8431abd --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/PlayerRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PlayerRepository extends JpaRepository { + + @Query("select p from Player p " + + "where lower(p.firstName) like lower(concat('%', :searchTerm, '%')) " + + "or lower(p.lastName) like lower(concat('%', :searchTerm, '%'))") + List search(@Param("searchTerm") String searchTerm); + + Player findByFirstNameAndLastName(String firstName, String lastName); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Rooster.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Rooster.java new file mode 100644 index 0000000..6410b07 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Rooster.java @@ -0,0 +1,51 @@ +package de.thpeetz.kontor.tysc.data; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = {@Index(columnList = "team_id, player_id, position_id")}, + uniqueConstraints = {@UniqueConstraint(name = "uniqueRooster", columnNames = {"year", "team_id", "player_id", "position_id"})} +) +public class Rooster extends AbstractEntity { + + private int year; + + @ManyToOne + @JoinColumn(name = "team_id") + @NotNull + private Team team; + + @ManyToOne + @JoinColumn(name = "player_id") + @NotNull + private Player player; + + @ManyToOne + @JoinColumn(name = "position_id") + @NotNull + private FieldPosition position; + + @Override + public String toString() { + return "Rooster{" + + "year=" + year + + ", team=" + team.getName() + + ", player=" + player.getFullName() + + ", position=" + position.getName() + + '}'; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/RoosterRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/RoosterRepository.java new file mode 100644 index 0000000..a24dda9 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/RoosterRepository.java @@ -0,0 +1,11 @@ +package de.thpeetz.kontor.tysc.data; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface RoosterRepository extends JpaRepository{ + + @Query("SELECT r FROM Rooster r WHERE r.player = ?1 AND r.team = ?2 AND r.position = ?3 AND r.year = ?4") + Rooster findByReferences(Player player, Team team, FieldPosition position, int year); + +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Sport.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Sport.java new file mode 100644 index 0000000..39333fd --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Sport.java @@ -0,0 +1,42 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.LinkedList; +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = {@Index(columnList = "name")}, + uniqueConstraints = {@UniqueConstraint(columnNames = { "name" })} +) +public class Sport extends AbstractEntity { + + @NotEmpty + @Column(unique = true) + private String name; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "sport") + @Nullable + private List teams = new LinkedList<>(); + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "sport") + @Nullable + private List positions = new LinkedList<>(); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/SportRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/SportRepository.java new file mode 100644 index 0000000..35efe85 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/SportRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SportRepository extends JpaRepository { + @Query("select s from Sport s " + + "where lower(s.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + Sport findByName(String name); + + List findByNameIgnoreCase(String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java new file mode 100644 index 0000000..b61fdd2 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java @@ -0,0 +1,50 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = {@Index(columnList = "name")}, + uniqueConstraints = {@UniqueConstraint(columnNames = { "name" })} +) +public class Team extends AbstractEntity { + + @NotEmpty + private String name; + + @NotEmpty + private String shortName; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "team") + @Nullable + private List roosters; + + @ManyToOne + @JoinColumn(name = "sport_id") + @NotNull + @JsonIgnoreProperties({ "teams" }) + private Sport sport; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/TeamRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/TeamRepository.java new file mode 100644 index 0000000..9f518d9 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/TeamRepository.java @@ -0,0 +1,27 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * The repository interface for managing teams. + */ +public interface TeamRepository extends JpaRepository { + + /** + * Searches for teams based on a search term. + * + * @param searchTerm the search term to match against team names + * @return a list of teams matching the search term + */ + @Query("select t from Team t " + + "where lower(t.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + Team findByName(String name); + + List findByNameIgnoreCase(String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Vendor.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Vendor.java new file mode 100644 index 0000000..f405565 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Vendor.java @@ -0,0 +1,38 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import de.thpeetz.kontor.common.data.AbstractEntity; +import jakarta.annotation.Nullable; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotEmpty; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * Represents a vendor entity. + */ +@Getter +@Setter +@ToString +@EqualsAndHashCode(callSuper=false) +@Entity +@Table(indexes = {@Index(columnList = "name")}, + uniqueConstraints = {@UniqueConstraint(columnNames = "name")} +) +public class Vendor extends AbstractEntity { + + @NotEmpty + private String name; + + @OneToMany(fetch = FetchType.EAGER, mappedBy = "vendor") + @Nullable + private List cardSets; +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/VendorRepository.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/VendorRepository.java new file mode 100644 index 0000000..4c7a304 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/VendorRepository.java @@ -0,0 +1,17 @@ +package de.thpeetz.kontor.tysc.data; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface VendorRepository extends JpaRepository { + + @Query("select v from Vendor v where lower(v.name) like lower(concat('%', :searchTerm, '%')) ") + List search(@Param("searchTerm") String searchTerm); + + Vendor findByName(String name); + + List findByNameIgnoreCase(String name); +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/services/CardService.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/services/CardService.java new file mode 100644 index 0000000..1cfedbd --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/services/CardService.java @@ -0,0 +1,93 @@ +package de.thpeetz.kontor.tysc.services; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import de.thpeetz.kontor.tysc.data.Card; +import de.thpeetz.kontor.tysc.data.CardRepository; +import de.thpeetz.kontor.tysc.data.CardSet; +import de.thpeetz.kontor.tysc.data.CardSetRepository; +import de.thpeetz.kontor.tysc.data.Vendor; +import de.thpeetz.kontor.tysc.data.VendorRepository; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class CardService { + + private VendorRepository vendorRepository; + + private CardSetRepository cardSetRepository; + + private CardRepository cardRepository; + + public CardService(VendorRepository vendorRepository, CardSetRepository cardSetRepository, CardRepository cardRepository) { + this.vendorRepository = vendorRepository; + this.cardSetRepository = cardSetRepository; + this.cardRepository = cardRepository; + } + + public List findAllVendors(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return vendorRepository.findAll(); + } else { + return vendorRepository.search(stringFilter); + } + } + + public void deleteVendor(Vendor vendor) { + vendorRepository.delete(vendor); + } + + public Vendor saveVendor(Vendor vendor) { + if (vendor == null) { + log.warn("Vendor is null. Are you sure you have connected your form to the application?"); + return null; + } + return vendorRepository.save(vendor); + } + + public List findAllCards(String stringFilter) { + log.info("find cards with filter: {}", stringFilter); + if (stringFilter == null || stringFilter.isEmpty()) { + List cards = cardRepository.findAll(); + log.debug("found {} cards", cards.size()); + return cards; + } else { + return cardRepository.search(stringFilter); + } + } + + public void deleteCard(Card card) { + cardRepository.delete(card); + } + + public Card saveCard(Card card) { + if (card == null) { + log.warn("Card is null. Are you sure you have connected your form to the application?"); + return null; + } + return cardRepository.save(card); + } + + public List findAllCardSets(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return cardSetRepository.findAll(); + } else { + return cardSetRepository.search(stringFilter); + } + } + + public CardSet saveCardSet(CardSet cardSet) { + if (cardSet == null) { + log.warn("CardSet is null. Are you sure you have connected your form to the application?"); + return null; + } + return cardSetRepository.save(cardSet); + } + + public void deleteCardSet(CardSet cardSet) { + cardSetRepository.delete(cardSet); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/services/SportService.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/services/SportService.java new file mode 100644 index 0000000..05482bf --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/services/SportService.java @@ -0,0 +1,147 @@ +package de.thpeetz.kontor.tysc.services; + +import java.util.List; + +import de.thpeetz.kontor.tysc.data.FieldPosition; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import de.thpeetz.kontor.tysc.data.Player; +import de.thpeetz.kontor.tysc.data.PlayerRepository; +import de.thpeetz.kontor.tysc.data.FieldPositionRepository; +import de.thpeetz.kontor.tysc.data.Rooster; +import de.thpeetz.kontor.tysc.data.RoosterRepository; +import de.thpeetz.kontor.tysc.data.Sport; +import de.thpeetz.kontor.tysc.data.SportRepository; +import de.thpeetz.kontor.tysc.data.Team; +import de.thpeetz.kontor.tysc.data.TeamRepository; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class SportService { + + @Autowired + private SportRepository sportRepository; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private FieldPositionRepository fieldPositionRepository; + + @Autowired + private PlayerRepository playerRepository; + + @Autowired + private RoosterRepository roosterRepository; + + public List findAllSports(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return sportRepository.findAll(); + } else { + return sportRepository.search(stringFilter); + } + } + + public void deleteSport(Sport sport) { + sportRepository.delete(sport); + } + + public Sport saveSport(Sport sport) { + if (sport == null) { + log.warn("Sport is null. Are you sure you have connected your form to the application?"); + return null; + } + return sportRepository.save(sport); + } + + public List findAllPlayers(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return playerRepository.findAll(); + } else { + return playerRepository.search(stringFilter); + } + } + + public Player savePlayer(Player player) { + if (player == null) { + log.warn("Player is null. Are you sure you have connected yout form to the application?"); + return null; + } + return playerRepository.save(player); + } + + public void deletePlayer(Player player) { + playerRepository.delete(player); + } + + public List findAllTeams(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return teamRepository.findAll(); + } else { + return teamRepository.search(stringFilter); + } + } + + public void deleteTeam(Team team) { + teamRepository.delete(team); + } + + public void saveTeam(Team team) { + if (team == null) { + log.warn("Team is null. Are you sure you have connected your form to the application?"); + return; + } + teamRepository.save(team); + } + + public List findAllPositions(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + return fieldPositionRepository.findAll(); + } else { + return fieldPositionRepository.search(stringFilter); + } + } + + public List findAllPositionsForSport(Sport sport) { + if (sport == null) { + return fieldPositionRepository.findAll(); + } else { + log.info("Find positions for Sport: {}", sport); + return fieldPositionRepository.findBySport(sport); + } + } + + public void savePosition(FieldPosition position) { + if (position == null) { + log.warn("Position is null. Are you sure you have connected your form to the application?"); + return; + } + fieldPositionRepository.save(position); + } + + public void deletePosition(FieldPosition position) { + fieldPositionRepository.delete(position); + } + + public List findAllRoosters() { + return roosterRepository.findAll(); + } + + public Rooster findRoosterByFields(Team team, Player player, FieldPosition position, Integer year) { + return roosterRepository.findByReferences(player, team, position, year); + } + + public void saveRooster(Rooster rooster) { + if (rooster == null) { + log.warn(""); + return; + } + roosterRepository.save(rooster); + } + + public void deleteRooster(Rooster rooster) { + roosterRepository.delete(rooster); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardForm.java new file mode 100644 index 0000000..174afdc --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardForm.java @@ -0,0 +1,110 @@ +package de.thpeetz.kontor.tysc.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.Card; +import de.thpeetz.kontor.tysc.data.Rooster; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CardForm extends FormLayout { + + TextField cardNumber = new TextField("CardNumber"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Card.class); + + public CardForm() { + addClassName("card-form"); + binder.bindInstanceFields(this); + + add(cardNumber, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setCard(Card card) { + binder.setBean(card); + } + + public void setRoosters(List roosters) { + log.info("Setting roosters: {}", roosters); + } + + public abstract static class CardFormEvent extends ComponentEvent { + private Card card; + + protected CardFormEvent(CardForm source, Card card) { + super(source, false); + this.card = card; + } + + public Card getCard() { + return card; + } + } + + public static class SaveEvent extends CardFormEvent { + SaveEvent(CardForm source, Card card) { + super(source, card); + } + } + + public static class DeleteEvent extends CardFormEvent { + DeleteEvent(CardForm source, Card card) { + super(source, card); + } + } + + public static class CloseEvent extends CardFormEvent { + CloseEvent(CardForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetForm.java new file mode 100644 index 0000000..d0e7e89 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetForm.java @@ -0,0 +1,103 @@ +package de.thpeetz.kontor.tysc.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.CardSet; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CardSetForm extends FormLayout { + + TextField name = new TextField("Name"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(CardSet.class); + + public CardSetForm() { + addClassName("cardSet-form"); + binder.bindInstanceFields(this); + + add(name, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setCardSet(CardSet cardSet) { + binder.setBean(cardSet); + } + + public abstract static class CardSetFormEvent extends ComponentEvent { + private CardSet cardSet; + + protected CardSetFormEvent(CardSetForm source, CardSet cardSet) { + super(source, false); + this.cardSet = cardSet; + } + + public CardSet getCardSet() { + return cardSet; + } + } + + public static class SaveEvent extends CardSetFormEvent { + SaveEvent(CardSetForm source, CardSet cardSet) { + super(source, cardSet); + } + } + + public static class DeleteEvent extends CardSetFormEvent { + DeleteEvent(CardSetForm source, CardSet cardSet) { + super(source, cardSet); + } + } + + public static class CloseEvent extends CardSetFormEvent { + CloseEvent(CardSetForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetView.java new file mode 100644 index 0000000..1a49896 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardSetView.java @@ -0,0 +1,129 @@ +package de.thpeetz.kontor.tysc.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.data.CardSet; +import de.thpeetz.kontor.tysc.services.CardService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.CARDSET_ROUTE, layout = MainLayout.class) +@PageTitle("CardSet | Tysc | Kontor") +public class CardSetView extends VerticalLayout { + + Grid grid = new Grid<>(CardSet.class); + TextField filterText = new TextField(); + CardSetForm form; + CardService service; + + public CardSetView(CardService service) { + this.service = service; + addClassName("cardSet-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("cardSet-grid"); + grid.setSizeFull(); + grid.setColumns("name", "vendor.name", "parallelSet", "insertSet"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editCardSet(event.getValue())); + } + + public CardSetForm getForm() { + return form; + } + + private void configureForm() { + form = new CardSetForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveCardSet); + form.addDeleteListener(this::deleteCardSet); + form.addCloseListener(e -> closeEditor()); + } + + private void saveCardSet(CardSetForm.SaveEvent event) { + service.saveCardSet(event.getCardSet()); + updateList(); + closeEditor(); + } + + private void deleteCardSet(CardSetForm.DeleteEvent event) { + service.deleteCardSet(event.getCardSet()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addCardSetButton = new Button("Add cardSet"); + addCardSetButton.addClickListener(click -> addCardSet()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addCardSetButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editCardSet(CardSet cardSet) { + if (cardSet == null) { + closeEditor(); + } else { + form.setCardSet(cardSet); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setCardSet(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addCardSet() { + grid.asSingleSelect().clear(); + editCardSet(new CardSet()); + } + + public void updateList() { + grid.setItems(service.findAllCardSets(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardView.java new file mode 100644 index 0000000..89cc1ce --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/CardView.java @@ -0,0 +1,129 @@ +package de.thpeetz.kontor.tysc.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.data.Card; +import de.thpeetz.kontor.tysc.services.CardService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.CARD_ROUTE, layout = MainLayout.class) +@PageTitle("Card | Tysc | Kontor") +public class CardView extends VerticalLayout { + + Grid grid = new Grid<>(Card.class); + TextField filterText = new TextField(); + CardForm form; + CardService service; + + public CardView(CardService service) { + this.service = service; + addClassName("card-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("card-grid"); + grid.setSizeFull(); + grid.setColumns("cardNumber", "year", "vendor.name", "cardSet.name", "rooster.player.fullName"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editCard(event.getValue())); + } + + public CardForm getForm() { + return form; + } + + private void configureForm() { + form = new CardForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveCard); + form.addDeleteListener(this::deleteCard); + form.addCloseListener(e -> closeEditor()); + } + + private void saveCard(CardForm.SaveEvent event) { + service.saveCard(event.getCard()); + updateList(); + closeEditor(); + } + + private void deleteCard(CardForm.DeleteEvent event) { + service.deleteCard(event.getCard()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addCardButton = new Button("Add card"); + addCardButton.addClickListener(click -> addCard()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addCardButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editCard(Card card) { + if (card == null) { + closeEditor(); + } else { + form.setCard(card); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setCard(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addCard() { + grid.asSingleSelect().clear(); + editCard(new Card()); + } + + public void updateList() { + grid.setItems(service.findAllCards(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerForm.java new file mode 100644 index 0000000..9730103 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerForm.java @@ -0,0 +1,116 @@ +package de.thpeetz.kontor.tysc.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.Player; +import de.thpeetz.kontor.tysc.data.Rooster; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PlayerForm extends FormLayout { + + TextField firstName = new TextField("First Name"); + TextField lastName = new TextField("Last Name"); + Grid roosters = new Grid<>(Rooster.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Player.class); + + public PlayerForm() { + addClassName("player-form"); + binder.bindInstanceFields(this); + + roosters.setColumns("team.name", "position.name", "year"); + roosters.getColumns().forEach(col -> col.setAutoWidth(true)); + add(firstName, lastName, roosters, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setPlayer(Player player) { + binder.setBean(player); + } + + public void setRoosters(List roosters) { + log.info("Setting roosters: {}", roosters); + this.roosters.setItems(roosters); + } + + public abstract static class PlayerFormEvent extends ComponentEvent { + private Player player; + + protected PlayerFormEvent(PlayerForm source, Player player) { + super(source, false); + this.player = player; + } + + public Player getPlayer() { + return player; + } + } + + public static class SaveEvent extends PlayerFormEvent { + SaveEvent(PlayerForm source, Player player) { + super(source, player); + } + } + + public static class DeleteEvent extends PlayerFormEvent { + DeleteEvent(PlayerForm source, Player player) { + super(source, player); + } + } + + public static class CloseEvent extends PlayerFormEvent { + CloseEvent(PlayerForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerView.java new file mode 100644 index 0000000..56d9588 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PlayerView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.tysc.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.data.Player; +import de.thpeetz.kontor.tysc.services.SportService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.PLAYER_ROUTE, layout = MainLayout.class) +@PageTitle("Player | Tysc | Kontor") +public class PlayerView extends VerticalLayout { + + Grid grid = new Grid<>(Player.class); + TextField filterText = new TextField(); + PlayerForm form; + SportService service; + + public PlayerView(SportService service) { + this.service = service; + addClassName("player-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("player-grid"); + grid.setSizeFull(); + grid.setColumns("firstName", "lastName"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editPlayer(event.getValue())); + } + + public PlayerForm getForm() { + return form; + } + + private void configureForm() { + form = new PlayerForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::savePlayer); + form.addDeleteListener(this::deletePlayer); + form.addCloseListener(e -> closeEditor()); + } + + private void savePlayer(PlayerForm.SaveEvent event) { + service.savePlayer(event.getPlayer()); + updateList(); + closeEditor(); + } + + private void deletePlayer(PlayerForm.DeleteEvent event) { + service.deletePlayer(event.getPlayer()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addPlayerButton = new Button("Add player"); + addPlayerButton.addClickListener(click -> addPlayer()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addPlayerButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editPlayer(Player player) { + if (player == null) { + closeEditor(); + } else { + form.setPlayer(player); + form.setRoosters(player.getRoosters()); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setPlayer(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addPlayer() { + grid.asSingleSelect().clear(); + editPlayer(new Player()); + } + + public void updateList() { + grid.setItems(service.findAllPlayers(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionForm.java new file mode 100644 index 0000000..f5b49bb --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionForm.java @@ -0,0 +1,115 @@ +package de.thpeetz.kontor.tysc.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.FieldPosition; +import de.thpeetz.kontor.tysc.data.Rooster; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PositionForm extends FormLayout { + + TextField name = new TextField("Name"); + Grid roosters = new Grid<>(Rooster.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(FieldPosition.class); + + public PositionForm() { + addClassName("position-form"); + binder.bindInstanceFields(this); + + roosters.setColumns("player.fullName", "team.name", "year"); + roosters.getColumns().forEach(col -> col.setAutoWidth(true)); + add(name, roosters, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setPosition(FieldPosition position) { + binder.setBean(position); + } + + public void setRoosters(List roosters) { + log.info("Setting roosters: {}", roosters); + this.roosters.setItems(roosters); + } + + public abstract static class PositionFormEvent extends ComponentEvent { + private FieldPosition position; + + protected PositionFormEvent(PositionForm source, FieldPosition position) { + super(source, false); + this.position = position; + } + + public FieldPosition getPosition() { + return position; + } + } + + public static class SaveEvent extends PositionFormEvent { + SaveEvent(PositionForm source, FieldPosition position) { + super(source, position); + } + } + + public static class DeleteEvent extends PositionFormEvent { + DeleteEvent(PositionForm source, FieldPosition position) { + super(source, position); + } + } + + public static class CloseEvent extends PositionFormEvent { + CloseEvent(PositionForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionView.java new file mode 100644 index 0000000..ad71bf0 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/PositionView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.tysc.views; + +import de.thpeetz.kontor.tysc.data.FieldPosition; +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.services.SportService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.POSITION_ROUTE, layout = MainLayout.class) +@PageTitle("Position | Tysc | Kontor") +public class PositionView extends VerticalLayout { + + Grid grid = new Grid<>(FieldPosition.class); + TextField filterText = new TextField(); + PositionForm form; + SportService service; + + public PositionView(SportService service) { + this.service = service; + addClassName("position-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("position-grid"); + grid.setSizeFull(); + grid.setColumns("shortName", "name", "sport.name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editPosition(event.getValue())); + } + + public PositionForm getForm() { + return form; + } + + private void configureForm() { + form = new PositionForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::savePosition); + form.addDeleteListener(this::deletePosition); + form.addCloseListener(e -> closeEditor()); + } + + private void savePosition(PositionForm.SaveEvent event) { + service.savePosition(event.getPosition()); + updateList(); + closeEditor(); + } + + private void deletePosition(PositionForm.DeleteEvent event) { + service.deletePosition(event.getPosition()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addPositionButton = new Button("Add position"); + addPositionButton.addClickListener(click -> addPosition()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addPositionButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editPosition(FieldPosition fieldPosition) { + if (fieldPosition == null) { + closeEditor(); + } else { + form.setPosition(fieldPosition); + form.setRoosters(fieldPosition.getRoosters()); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setPosition(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addPosition() { + grid.asSingleSelect().clear(); + editPosition(new FieldPosition()); + } + + public void updateList() { + grid.setItems(service.findAllPositions(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterForm.java new file mode 100644 index 0000000..5f9d259 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterForm.java @@ -0,0 +1,117 @@ +package de.thpeetz.kontor.tysc.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.FieldPosition; +import de.thpeetz.kontor.tysc.data.Player; +import de.thpeetz.kontor.tysc.data.Rooster; +import de.thpeetz.kontor.tysc.data.Team; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class RoosterForm extends FormLayout { + IntegerField year = new IntegerField("Year"); + ComboBox team = new ComboBox<>("Team"); + ComboBox player = new ComboBox<>("Player"); + ComboBox position = new ComboBox<>("Position"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Rooster.class); + + public RoosterForm(List teams, List players, List positions) { + addClassName("rooster-form"); + binder.bindInstanceFields(this); + + team.setItems(teams); + team.setItemLabelGenerator(Team::getName); + player.setItems(players); + player.setItemLabelGenerator(Player::getFullName); + position.setItems(positions); + position.setItemLabelGenerator(FieldPosition::getName); + add(year, team, player, position, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setRooster(Rooster rooster) { + binder.setBean(rooster); + } + + public abstract static class RoosterFormEvent extends ComponentEvent { + private Rooster rooster; + + protected RoosterFormEvent(RoosterForm source, Rooster rooster) { + super(source, false); + this.rooster = rooster; + } + + public Rooster getRooster() { + return rooster; + } + } + + public static class SaveEvent extends RoosterFormEvent { + SaveEvent(RoosterForm source, Rooster rooster) { + super(source, rooster); + } + } + + public static class DeleteEvent extends RoosterFormEvent { + DeleteEvent(RoosterForm source, Rooster rooster) { + super(source, rooster); + } + } + + public static class CloseEvent extends RoosterFormEvent { + CloseEvent(RoosterForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterView.java new file mode 100644 index 0000000..92da6eb --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/RoosterView.java @@ -0,0 +1,121 @@ +package de.thpeetz.kontor.tysc.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.data.Rooster; +import de.thpeetz.kontor.tysc.services.SportService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.ROOSTER_ROUTE, layout = MainLayout.class) +@PageTitle("Rooster | Tysc | Kontor") +public class RoosterView extends VerticalLayout { + + Grid grid = new Grid<>(Rooster.class); + RoosterForm form; + SportService service; + + public RoosterView(SportService service) { + this.service = service; + addClassName("rooster-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("rooster-grid"); + grid.setSizeFull(); + grid.setColumns("year", "team.name", "player.fullName", "position.name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editRooster(event.getValue())); + } + + public RoosterForm getForm() { + return form; + } + + private void configureForm() { + form = new RoosterForm(service.findAllTeams(null), service.findAllPlayers(null), service.findAllPositions(null)); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveRooster); + form.addDeleteListener(this::deleteRooster); + form.addCloseListener(e -> closeEditor()); + } + + private void saveRooster(RoosterForm.SaveEvent event) { + service.saveRooster(event.getRooster()); + updateList(); + closeEditor(); + } + + private void deleteRooster(RoosterForm.DeleteEvent event) { + service.deleteRooster(event.getRooster()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + Button addRoosterButton = new Button("Add Rooster"); + addRoosterButton.addClickListener(click -> addRooster()); + + HorizontalLayout toolbar = new HorizontalLayout(addRoosterButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editRooster(Rooster rooster) { + if (rooster == null) { + closeEditor(); + } else { + form.setRooster(rooster); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setRooster(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addRooster() { + grid.asSingleSelect().clear(); + editRooster(new Rooster()); + } + + public void updateList() { + grid.setItems(service.findAllRoosters()); + } +} \ No newline at end of file diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportForm.java new file mode 100644 index 0000000..30582e1 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportForm.java @@ -0,0 +1,103 @@ +package de.thpeetz.kontor.tysc.views; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.Sport; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SportForm extends FormLayout { + + TextField name = new TextField("Name"); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Sport.class); + + public SportForm() { + addClassName("sport-form"); + binder.bindInstanceFields(this); + + add(name, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setSport(Sport sport) { + binder.setBean(sport); + } + + public abstract static class SportFormEvent extends ComponentEvent { + private Sport sport; + + protected SportFormEvent(SportForm source, Sport sport) { + super(source, false); + this.sport = sport; + } + + public Sport getSport() { + return sport; + } + } + + public static class SaveEvent extends SportFormEvent { + SaveEvent(SportForm source, Sport sport) { + super(source, sport); + } + } + + public static class DeleteEvent extends SportFormEvent { + DeleteEvent(SportForm source, Sport sport) { + super(source, sport); + } + } + + public static class CloseEvent extends SportFormEvent { + CloseEvent(SportForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportView.java new file mode 100644 index 0000000..c6008c3 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/SportView.java @@ -0,0 +1,129 @@ +package de.thpeetz.kontor.tysc.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.data.Sport; +import de.thpeetz.kontor.tysc.services.SportService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.SPORT_ROUTE, layout = MainLayout.class) +@PageTitle("Sport | Tysc | Kontor") +public class SportView extends VerticalLayout { + + Grid grid = new Grid<>(Sport.class); + TextField filterText = new TextField(); + SportForm form; + SportService service; + + public SportView(SportService service) { + this.service = service; + addClassName("sport-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("sport-grid"); + grid.setSizeFull(); + grid.setColumns("name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editSport(event.getValue())); + } + + public SportForm getForm() { + return form; + } + + private void configureForm() { + form = new SportForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveSport); + form.addDeleteListener(this::deleteSport); + form.addCloseListener(e -> closeEditor()); + } + + private void saveSport(SportForm.SaveEvent event) { + service.saveSport(event.getSport()); + updateList(); + closeEditor(); + } + + private void deleteSport(SportForm.DeleteEvent event) { + service.deleteSport(event.getSport()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addSportButton = new Button("Add sport"); + addSportButton.addClickListener(click -> addSport()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addSportButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editSport(Sport sport) { + if (sport == null) { + closeEditor(); + } else { + form.setSport(sport); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setSport(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addSport() { + grid.asSingleSelect().clear(); + editSport(new Sport()); + } + + public void updateList() { + grid.setItems(service.findAllSports(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamForm.java new file mode 100644 index 0000000..63cb56b --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamForm.java @@ -0,0 +1,115 @@ +package de.thpeetz.kontor.tysc.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.Rooster; +import de.thpeetz.kontor.tysc.data.Team; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TeamForm extends FormLayout { + + TextField name = new TextField("Name"); + Grid roosters = new Grid<>(Rooster.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Team.class); + + public TeamForm() { + addClassName("team-form"); + binder.bindInstanceFields(this); + + roosters.setColumns("player.fullName", "position.name", "year"); + roosters.getColumns().forEach(col -> col.setAutoWidth(true)); + add(name, roosters, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setTeam(Team team) { + binder.setBean(team); + } + + public void setRoosters(List roosters) { + log.info("Setting roosters: {}", roosters); + this.roosters.setItems(roosters); + } + + public abstract static class TeamFormEvent extends ComponentEvent { + private Team team; + + protected TeamFormEvent(TeamForm source, Team team) { + super(source, false); + this.team = team; + } + + public Team getTeam() { + return team; + } + } + + public static class SaveEvent extends TeamFormEvent { + SaveEvent(TeamForm source, Team team) { + super(source, team); + } + } + + public static class DeleteEvent extends TeamFormEvent { + DeleteEvent(TeamForm source, Team team) { + super(source, team); + } + } + + public static class CloseEvent extends TeamFormEvent { + CloseEvent(TeamForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamView.java new file mode 100644 index 0000000..1e48f98 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TeamView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.tysc.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.data.Team; +import de.thpeetz.kontor.tysc.services.SportService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.TEAM_ROUTE, layout = MainLayout.class) +@PageTitle("Team | Tysc | Kontor") +public class TeamView extends VerticalLayout { + + Grid grid = new Grid<>(Team.class); + TextField filterText = new TextField(); + TeamForm form; + SportService service; + + public TeamView(SportService service) { + this.service = service; + addClassName("team-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("team-grid"); + grid.setSizeFull(); + grid.setColumns("name", "shortName", "sport.name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editTeam(event.getValue())); + } + + public TeamForm getForm() { + return form; + } + + private void configureForm() { + form = new TeamForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveTeam); + form.addDeleteListener(this::deleteTeam); + form.addCloseListener(e -> closeEditor()); + } + + private void saveTeam(TeamForm.SaveEvent event) { + service.saveTeam(event.getTeam()); + updateList(); + closeEditor(); + } + + private void deleteTeam(TeamForm.DeleteEvent event) { + service.deleteTeam(event.getTeam()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addTeamButton = new Button("Add team"); + addTeamButton.addClickListener(click -> addTeam()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addTeamButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editTeam(Team team) { + if (team == null) { + closeEditor(); + } else { + form.setTeam(team); + form.setRoosters(team.getRoosters()); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setTeam(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addTeam() { + grid.asSingleSelect().clear(); + editTeam(new Team()); + } + + public void updateList() { + grid.setItems(service.findAllTeams(filterText.getValue())); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TyscLayout.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TyscLayout.java new file mode 100644 index 0000000..66fb09b --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/TyscLayout.java @@ -0,0 +1,41 @@ +package de.thpeetz.kontor.tysc.views; + +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.theme.lumo.LumoUtility; + +import de.thpeetz.kontor.admin.services.AdminService; +import de.thpeetz.kontor.common.views.KontorLayoutUtil; +import de.thpeetz.kontor.security.SecurityService; +import de.thpeetz.kontor.tysc.TyscConstants; +import lombok.extern.slf4j.Slf4j; + +/** + * Represents a custom layout for the comic view in the application. + * This layout extends the AppLayout class. + */ +@Slf4j +public class TyscLayout extends AppLayout { + + private final AdminService adminService; + + private final SecurityService securityService; + + public TyscLayout(AdminService adminService, SecurityService securityService) { + this.adminService = adminService; + this.securityService = securityService; + + KontorLayoutUtil layout = new KontorLayoutUtil(this, adminService, securityService); + layout.setSecondaryNavigation(getSecondaryNavigation()); + layout.createHeader(TyscConstants.TYSC); + } + + private HorizontalLayout getSecondaryNavigation() { + HorizontalLayout navigation = new HorizontalLayout(); + navigation.addClassNames(LumoUtility.JustifyContent.CENTER, LumoUtility.Gap.SMALL, LumoUtility.Height.MEDIUM); + navigation.add(TyscConstants.getSportLink(), TyscConstants.getTeamLink(), TyscConstants.getPositionLink(), + TyscConstants.getPlayerLink(), TyscConstants.getRoosterLink(), TyscConstants.getVendorLink(), + TyscConstants.getCardSetLink(), TyscConstants.getCardLink()); + return navigation; + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorForm.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorForm.java new file mode 100644 index 0000000..254b1ff --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorForm.java @@ -0,0 +1,115 @@ +package de.thpeetz.kontor.tysc.views; + +import java.util.List; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; + +import de.thpeetz.kontor.tysc.data.Vendor; +import de.thpeetz.kontor.tysc.data.CardSet; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class VendorForm extends FormLayout { + + TextField name = new TextField("Name"); + Grid cardSets = new Grid<>(CardSet.class); + + Button save = new Button("Save"); + Button delete = new Button("Delete"); + Button close = new Button("Cancel"); + + Binder binder = new BeanValidationBinder<>(Vendor.class); + + public VendorForm() { + addClassName("vendor-form"); + binder.bindInstanceFields(this); + + cardSets.setColumns("name", "parallelSet"); + cardSets.getColumns().forEach(col -> col.setAutoWidth(true)); + add(name, cardSets, createButtonsLayout()); + } + + private HorizontalLayout createButtonsLayout() { + save.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + delete.addThemeVariants(ButtonVariant.LUMO_ERROR); + close.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + save.addClickShortcut(Key.ENTER); + close.addClickShortcut(Key.ESCAPE); + + save.addClickListener(event -> validateAndSave()); + delete.addClickListener(event -> fireEvent(new DeleteEvent(this, binder.getBean()))); + close.addClickListener(event -> fireEvent(new CloseEvent(this))); + + binder.addStatusChangeListener(e -> save.setEnabled(binder.isValid())); + return new HorizontalLayout(save, delete, close); + } + + private void validateAndSave() { + if (binder.isValid()) { + fireEvent(new SaveEvent(this, binder.getBean())); + } + } + + public void setVendor(Vendor vendor) { + binder.setBean(vendor); + } + + public void setCardSets(List sets) { + log.info("Setting card sets: {}", sets); + this.cardSets.setItems(sets); + } + + public abstract static class VendorFormEvent extends ComponentEvent { + private Vendor vendor; + + protected VendorFormEvent(VendorForm source, Vendor vendor) { + super(source, false); + this.vendor = vendor; + } + + public Vendor getVendor() { + return vendor; + } + } + + public static class SaveEvent extends VendorFormEvent { + SaveEvent(VendorForm source, Vendor vendor) { + super(source, vendor); + } + } + + public static class DeleteEvent extends VendorFormEvent { + DeleteEvent(VendorForm source, Vendor vendor) { + super(source, vendor); + } + } + + public static class CloseEvent extends VendorFormEvent { + CloseEvent(VendorForm source) { + super(source, null); + } + } + + public void addDeleteListener(ComponentEventListener listener) { + addListener(DeleteEvent.class, listener); + } + + public void addSaveListener(ComponentEventListener listener) { + addListener(SaveEvent.class, listener); + } + + public void addCloseListener(ComponentEventListener listener) { + addListener(CloseEvent.class, listener); + } +} diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorView.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorView.java new file mode 100644 index 0000000..08b5d32 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/views/VendorView.java @@ -0,0 +1,130 @@ +package de.thpeetz.kontor.tysc.views; + +import org.springframework.context.annotation.Scope; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.value.ValueChangeMode; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; + +import de.thpeetz.kontor.common.views.MainLayout; +import de.thpeetz.kontor.tysc.TyscConstants; +import de.thpeetz.kontor.tysc.data.Vendor; +import de.thpeetz.kontor.tysc.services.CardService; +import jakarta.annotation.security.PermitAll; + +@SpringComponent +@Scope("prototype") +@PermitAll +@Route(value = TyscConstants.VENDOR_ROUTE, layout = MainLayout.class) +@PageTitle("Vendor | Tysc | Kontor") +public class VendorView extends VerticalLayout { + + Grid grid = new Grid<>(Vendor.class); + TextField filterText = new TextField(); + VendorForm form; + CardService service; + + public VendorView(CardService service) { + this.service = service; + addClassName("vendor-view"); + setSizeFull(); + configureGrid(); + configureForm(); + + add(getToolbar(), getContent()); + updateList(); + } + + public Grid getGrid() { + return grid; + } + + private void configureGrid() { + grid.addClassName("vendor-grid"); + grid.setSizeFull(); + grid.setColumns("name"); + grid.getColumns().forEach(col -> col.setAutoWidth(true)); + grid.asSingleSelect().addValueChangeListener(event -> editVendor(event.getValue())); + } + + public VendorForm getForm() { + return form; + } + + private void configureForm() { + form = new VendorForm(); + form.setWidth("25em"); + form.setVisible(false); + form.addSaveListener(this::saveVendor); + form.addDeleteListener(this::deleteVendor); + form.addCloseListener(e -> closeEditor()); + } + + private void saveVendor(VendorForm.SaveEvent event) { + service.saveVendor(event.getVendor()); + updateList(); + closeEditor(); + } + + private void deleteVendor(VendorForm.DeleteEvent event) { + service.deleteVendor(event.getVendor()); + updateList(); + closeEditor(); + } + + private Component getContent() { + HorizontalLayout content = new HorizontalLayout(grid, form); + content.setFlexGrow(2, grid); + content.setFlexGrow(1, form); + content.addClassName("content"); + content.setSizeFull(); + return content; + } + + private HorizontalLayout getToolbar() { + filterText.setPlaceholder("Filter by name..."); + filterText.setClearButtonVisible(true); + filterText.setValueChangeMode(ValueChangeMode.LAZY); + filterText.addValueChangeListener(e -> updateList()); + + Button addVendorButton = new Button("Add vendor"); + addVendorButton.addClickListener(click -> addVendor()); + + HorizontalLayout toolbar = new HorizontalLayout(filterText, addVendorButton); + toolbar.addClassName("toolbar"); + return toolbar; + } + + public void editVendor(Vendor vendor) { + if (vendor == null) { + closeEditor(); + } else { + form.setVendor(vendor); + form.setCardSets(vendor.getCardSets()); + form.setVisible(true); + addClassName("editing"); + } + } + + private void closeEditor() { + form.setVendor(null); + form.setVisible(false); + removeClassName("editing"); + } + + private void addVendor() { + grid.asSingleSelect().clear(); + editVendor(new Vendor()); + } + + public void updateList() { + grid.setItems(service.findAllVendors(filterText.getValue())); + } +} diff --git a/springboot/src/main/resources/META-INF/resources/icons/icon.png b/springboot/src/main/resources/META-INF/resources/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..77bb2c9ad73c16d2878c34fef9443c91466178df GIT binary patch literal 45967 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 literal 0 HcmV?d00001 diff --git a/springboot/src/main/resources/META-INF/resources/images/offline.png b/springboot/src/main/resources/META-INF/resources/images/offline.png new file mode 100644 index 0000000000000000000000000000000000000000..723e24ce7525be86a728d07a8e98f6e1753b745b GIT binary patch literal 9507 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 literal 0 HcmV?d00001 diff --git a/springboot/src/main/resources/META-INF/resources/offline.html b/springboot/src/main/resources/META-INF/resources/offline.html new file mode 100644 index 0000000..791d1e8 --- /dev/null +++ b/springboot/src/main/resources/META-INF/resources/offline.html @@ -0,0 +1,38 @@ + + + + + + + Offline | Vaadin CRM + + + + +
+ VaadinCRM is offline +

Oh deer, you're offline

+

Your internet connection is offline. Get back online to continue using Vaadin CRM.

+
+ + + diff --git a/springboot/src/main/resources/application.yml b/springboot/src/main/resources/application.yml new file mode 100644 index 0000000..fd8dfff --- /dev/null +++ b/springboot/src/main/resources/application.yml @@ -0,0 +1,67 @@ +server: + port: 8085 +app: + name: 'Kontor' + shortName: 'Kontor' + description: 'Kontor is a Spring Boot application' +spring: + profiles: + active: local,dev,test,prod + devtools: + add-properties: false + datasource: + driverClassName: org.mariadb.jdbc.Driver + url: jdbc:mariadb://localhost:3306/kontor + username: 'kontor' + password: 'kontor' + #driverClassName: org.hsqldb.jdbc.JDBCDriver + #url: jdbc:hsqldb:file:kontorHSQLDB + #username: 'sa' + #password: 'sa' + #jpa + #database-platform: org.hibernate.community.dialect.SQLiteDialect + #datasource + #driverClassName: org.sqlite.JDBC + #url: "jdbc:sqlite:file:./kontorDb?cache=shared" + #username=sa + #password=sa + jpa: + defer-datasource-initialization: true + #hibernate.ddl-auto=create-drop + hibernate: + ddl-auto: update + #ddl-auto: create-drop + show-sql: false + sql: + init: + mode: never + mustache: + check-template-location: false +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always +logging: + level: + org: + atmosphere: INFO + hibernate: INFO + springframework: + web: INFO + guru: + springframework: + controllers: DEBUG +jwt: + auth: + secret: 'J6GOtcwC2NJI1l0VkHu20PacPFGTxpirBxWwynoHjsc=' +mail: + protocol: 'imap' + host: 'corky.svpdata.eu' + port: 143 + userName: 'thomas.peetz@thpeetz.de' + password: 'fS9f4JYDIO7A' + starttls: true diff --git a/springboot/src/main/resources/banner.txt b/springboot/src/main/resources/banner.txt new file mode 100644 index 0000000..b21c682 --- /dev/null +++ b/springboot/src/main/resources/banner.txt @@ -0,0 +1,8 @@ +,--. ,--. ,--. +| .' / ,---. ,--,--, ,-' '-. ,---. ,--.--. +| . ' | .-. || \'-. .-'| .-. || .--' +| |\ \' '-' '| || | | | ' '-' '| | +`--' '--' `---' `--''--' `--' `---' `--' + +${application.title} ${application.version} +Powered by Spring Boot ${spring-boot.version} \ No newline at end of file diff --git a/springboot/src/main/resources/logback-spring.xml b/springboot/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..b085f8c --- /dev/null +++ b/springboot/src/main/resources/logback-spring.xml @@ -0,0 +1,42 @@ + + + + + + + + + %white(%d{ISO8601}) %highlight(%-5level) [%green(%t)] %yellow(%C{1}) : %msg%n%throwable + + + + + + ${LOGS}/spring-boot-logger.log + + %d %p %C{1} [%t] %m%n + + + + + ${LOGS}/archived/spring-boot-logger-%d{yyyy-MM-dd}.%i.log + + + 10MB + + + + + + + + + + + + diff --git a/springboot/src/test/java/de/thpeetz/kontor/ApplicationTests.java b/springboot/src/test/java/de/thpeetz/kontor/ApplicationTests.java new file mode 100644 index 0000000..abf5c95 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/ApplicationTests.java @@ -0,0 +1,13 @@ +package de.thpeetz.kontor; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationTests { + + @Test + void contextLoads() { + + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/TestConstants.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/TestConstants.java new file mode 100644 index 0000000..bb9d04d --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/TestConstants.java @@ -0,0 +1,19 @@ +package de.thpeetz.kontor.bookshelf; + +public class TestConstants { + + public static final Integer ARTICLEAUTHOR_COUNT = 0; + public static final Integer ARTICLE_COUNT = 0; + public static final Integer AUTHOR_COUNT = 1; + public static final Integer BOOKAUTHOR_COUNT = 0; + public static final Integer BOOK_COUNT = 0; + public static final Integer PUBLISHER_COUNT = 0; + public static final String ARTICLE_TITLE = "Title"; + public static final String AUTHOR_FIRSTNAME = "Firstname"; + public static final String AUTHOR_LASTNAME = "Lastname"; + public static final String PUBLISHER_NIKOL = "Nikol Verlags GmbH"; + public static final String PUBLISHER_NAME = "Publisher"; + public static final String PUBLISHER_SEARCH = "kol"; + public static final String BOOK_TITLE = "Book Title"; + public static final String BOOK_ISBN = "978-3-123-467-890"; +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepositoryTest.java new file mode 100644 index 0000000..b54b976 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorRepositoryTest.java @@ -0,0 +1,74 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Disabled +class ArticleAuthorRepositoryTest { + + @Autowired + ArticleAuthorRepository articleAuthorRepository; + + @Autowired + AuthorRepository authorRepository; + + @Autowired + ArticleRepository articleRepository; + + @Test + @Order(1) + void saveArticleAuthor() { + Author author = new Author(); + author.setFirstName(TestConstants.AUTHOR_FIRSTNAME); + author.setLastName(TestConstants.AUTHOR_LASTNAME); + Author savedAuthor = authorRepository.save(author); + assertEquals(TestConstants.AUTHOR_COUNT+1, authorRepository.findAll().size()); + Article article = new Article(); + article.setTitle(TestConstants.ARTICLE_TITLE); + Article savedArticle = articleRepository.save(article); + ArticleAuthor articleAuthor = new ArticleAuthor(); + articleAuthor.setArticle(article); + articleAuthor.setAuthor(author); + ArticleAuthor savedArticleAuthor = articleAuthorRepository.save(articleAuthor); + assertEquals(TestConstants.ARTICLEAUTHOR_COUNT+1, articleAuthorRepository.findAll().size()); + } + + @Test + @Order(2) + void testFindByArticle() { + Article article = articleRepository.findByTitle(TestConstants.ARTICLE_TITLE).get(0); + assertEquals(1, articleAuthorRepository.findByArticle(article).size()); + } + + @Test + @Order(3) + void testFindByAuthor() { + Author author = authorRepository.findByFirstNameAndLastName(TestConstants.AUTHOR_FIRSTNAME, TestConstants.AUTHOR_LASTNAME); + assertEquals(1, articleAuthorRepository.findByAuthor(author).size()); + } + + @Test + @Order(4) + void deleteArticleAuthor() { + assertEquals(1, articleAuthorRepository.findAll().size()); + ArticleAuthor articleAuthor = articleAuthorRepository.findAll().get(0); + Author author = articleAuthor.getAuthor(); + author.getArticleAuthors().remove(articleAuthor); + author = authorRepository.save(author); + Article article = articleAuthor.getArticle(); + article.getAuthors().remove(articleAuthor); + article = articleRepository.save(article); + articleAuthorRepository.delete(articleAuthor); + assertEquals(TestConstants.ARTICLEAUTHOR_COUNT, articleAuthorRepository.findAll().size()); + authorRepository.delete(author); + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + articleRepository.delete(article); + assertEquals(TestConstants.ARTICLE_COUNT, articleRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorTest.java new file mode 100644 index 0000000..bd14fd3 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleAuthorTest.java @@ -0,0 +1,77 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +@Disabled +class ArticleAuthorTest { + + @Autowired + ArticleRepository articleRepository; + + @Autowired + AuthorRepository authorRepository; + + @Autowired + ArticleAuthorRepository articleAuthorRepository; + + @Test + @Order(1) + void checkInitialLoad() { + assertEquals(TestConstants.ARTICLEAUTHOR_COUNT, articleAuthorRepository.findAll().size()); + } + + @Test + @Order(2) + void exceptionThrownWhenSavingEmptyValues() { + ArticleAuthor articleAuthor = new ArticleAuthor(); + assertThrows(TransactionSystemException.class, () -> { + articleAuthorRepository.save(articleAuthor); + }); + } + + @Test + @Order(3) + void exceptionThrownWhenSavingIdenticalEntry() { + Author author = new Author(); + author.setFirstName(TestConstants.AUTHOR_FIRSTNAME); + author.setLastName(TestConstants.AUTHOR_LASTNAME); + Author savedAuthor = authorRepository.save(author); + Article article = new Article(); + article.setTitle(TestConstants.ARTICLE_TITLE); + Article savedArticle = articleRepository.save(article); + ArticleAuthor articleAuthor = new ArticleAuthor(); + articleAuthor.setArticle(article); + articleAuthor.setAuthor(author); + ArticleAuthor savedArticleAuthor = articleAuthorRepository.save(articleAuthor); + ArticleAuthor articleAuthor1 = new ArticleAuthor(); + articleAuthor1.setArticle(article); + articleAuthor1.setAuthor(author); + assertThrows(DataIntegrityViolationException.class, () -> { + articleAuthorRepository.save(articleAuthor1); + }); + articleAuthorRepository.delete(savedArticleAuthor); + assertEquals(TestConstants.ARTICLEAUTHOR_COUNT, articleAuthorRepository.findAll().size()); + savedAuthor.getArticleAuthors().remove(savedArticleAuthor); + authorRepository.save(savedAuthor); + authorRepository.delete(savedAuthor); + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + savedArticle.getAuthors().remove(savedArticleAuthor); + articleRepository.save(savedArticle); + articleRepository.delete(savedArticle); + assertEquals(TestConstants.ARTICLE_COUNT, articleRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleRepositoryTest.java new file mode 100644 index 0000000..7d74119 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleRepositoryTest.java @@ -0,0 +1,61 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ArticleRepositoryTest { + + @Autowired + ArticleRepository articleRepository; + + @Test + @Order(1) + void checkInitialLoad() { + assertEquals(TestConstants.ARTICLE_COUNT, articleRepository.findAll().size()); + } + + @Test + @Order(2) + void saveArticle() { + Article article = new Article(); + article.setTitle(TestConstants.ARTICLE_TITLE); + articleRepository.save(article); + assertEquals(TestConstants.ARTICLE_COUNT+1, articleRepository.findAll().size()); + } + + @Test + @Order(3) + void search() { + List
articles = articleRepository.search(TestConstants.ARTICLE_TITLE.substring(2,5)); + assertEquals(1, articles.size()); + assertEquals(0, articleRepository.search(TestConstants.BOOK_TITLE).size()); + } + + @Test + @Order(4) + void findByTitle() { + List
articles = articleRepository.search(TestConstants.ARTICLE_TITLE); + assertEquals(1, articles.size()); + } + + @Test + @Order(5) + void deleteArticle() { + List
articles = articleRepository.findAll(); + assertEquals(1, articles.size()); + assertEquals(0, articles.get(0).getAuthors().size()); + articleRepository.delete(articles.get(0)); + assertEquals(TestConstants.ARTICLE_COUNT, articleRepository.findAll().size()); + } +} \ No newline at end of file diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleTest.java new file mode 100644 index 0000000..2179dfe --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/ArticleTest.java @@ -0,0 +1,45 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ArticleTest { + + @Autowired + ArticleRepository articleRepository; + + @Test + void checkInitialLoad() { + assertEquals(TestConstants.BOOK_COUNT, articleRepository.findAll().size()); + } + + @Test + void exceptionThrownWhenSavingEmptyFields() { + Article article = new Article(); + assertThrows(TransactionSystemException.class, () -> { + articleRepository.save(article); + }); + } + + @Test + void exceptionThrownWhenSavingIdenticalTitle() { + Article article = new Article(); + article.setTitle(TestConstants.ARTICLE_TITLE); + Article savedArticle = articleRepository.save(article); + Article article1 = new Article(); + article1.setTitle(article.getTitle()); + assertThrows(DataIntegrityViolationException.class, ()-> { + articleRepository.save(article1); + }); + articleRepository.delete(savedArticle); + assertEquals(TestConstants.ARTICLE_COUNT, articleRepository.findAll().size()); + } +} \ No newline at end of file diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorRepositoryTest.java new file mode 100644 index 0000000..f984d11 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorRepositoryTest.java @@ -0,0 +1,69 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +@Disabled +class AuthorRepositoryTest { + + @Autowired + AuthorRepository authorRepository; + + @Test + @Order(1) + void checkInitialLoad() { + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + } + + @Test + @Order(2) + void saveAutor() { + Author author = new Author(); + author.setFirstName(TestConstants.AUTHOR_FIRSTNAME); + author.setLastName(TestConstants.AUTHOR_LASTNAME); + authorRepository.save(author); + assertEquals(TestConstants.AUTHOR_COUNT+1, authorRepository.findAll().size()); + } + + @Test + @Order(3) + void findAuthor() { + Author existingAuthor = authorRepository.findAll().get(0); + assertNotNull(existingAuthor); + Author found = authorRepository.findByFirstNameAndLastName(existingAuthor.getFirstName(), existingAuthor.getLastName()); + assertEquals(existingAuthor, found); + } + + @Test + @Order(4) + void searchAuthor() { + List authors = authorRepository.search("glas"); + assertEquals(1, authors.size()); + assertEquals("Douglas", authors.get(0).getFirstName()); + List authors2 = authorRepository.search("dams"); + assertEquals(1, authors2.size()); + assertEquals("Adams", authors2.get(0).getLastName()); + } + + @Test + @Order(5) + void deleteAuthor() { + Author existingAuthor = authorRepository.findByFirstNameAndLastName(TestConstants.AUTHOR_FIRSTNAME, TestConstants.AUTHOR_LASTNAME); + assertNotNull(existingAuthor); + authorRepository.delete(existingAuthor); + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorTest.java new file mode 100644 index 0000000..149c263 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/AuthorTest.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class AuthorTest { + + @Autowired + private AuthorRepository authorRepository; + + @Test + void throwExceptionWhenPlayerSavedWithEmptyName() { + Author author = new Author(); + assertThrows(TransactionSystemException.class, () -> { + authorRepository.save(author); + }); + } + + @Test + void throwExceptionWhenPlayerSavedWithExistingName() { + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + Author existingAuthor = new Author(); + existingAuthor.setFirstName(TestConstants.AUTHOR_FIRSTNAME); + existingAuthor.setLastName(TestConstants.AUTHOR_LASTNAME); + existingAuthor = authorRepository.save(existingAuthor); + assertEquals(TestConstants.AUTHOR_COUNT+1, authorRepository.findAll().size()); + Author author = new Author(); + author.setFirstName(existingAuthor.getFirstName()); + author.setLastName(existingAuthor.getLastName()); + assertThrows(DataIntegrityViolationException.class, () -> { + authorRepository.save(author); + }); + assertNotNull(existingAuthor); + authorRepository.delete(existingAuthor); + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepositoryTest.java new file mode 100644 index 0000000..4df5fd9 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorRepositoryTest.java @@ -0,0 +1,94 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Disabled +class BookAuthorRepositoryTest { + + @Autowired + BookRepository bookRepository; + + @Autowired + AuthorRepository authorRepository; + + @Autowired + BookAuthorRepository bookAuthorRepository; + + @Autowired + BookshelfPublisherRepository bookshelfPublisherRepository; + + @Test + @Order(1) + void saveBookAuthor() { + Author author = new Author(); + author.setFirstName(TestConstants.AUTHOR_FIRSTNAME); + author.setLastName(TestConstants.AUTHOR_LASTNAME); + Author savedAuthor = authorRepository.save(author); + assertEquals(TestConstants.AUTHOR_COUNT+1, authorRepository.findAll().size()); + BookshelfPublisher publisher = new BookshelfPublisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + BookshelfPublisher savedPublisher = bookshelfPublisherRepository.save(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT+1, bookshelfPublisherRepository.findAll().size()); + Book book = new Book(); + book.setTitle(TestConstants.ARTICLE_TITLE); + book.setIsbn(TestConstants.BOOK_ISBN); + book.setPublisher(savedPublisher); + Book savedBook = bookRepository.save(book); + assertEquals(TestConstants.BOOK_COUNT+1, bookRepository.findAll().size()); + BookAuthor bookAuthor = new BookAuthor(); + bookAuthor.setBook(savedBook); + bookAuthor.setAuthor(savedAuthor); + BookAuthor savedBookAuthor = bookAuthorRepository.save(bookAuthor); + assertEquals(TestConstants.BOOKAUTHOR_COUNT+1, bookAuthorRepository.findAll().size()); + } + + @Test + @Order(2) + void testFindByBook() { + List bookAuthors = bookAuthorRepository.findAll(); + assertEquals(1, bookAuthors.size()); + Book book = bookAuthors.get(0).getBook(); + assertEquals(1, bookAuthorRepository.findByBook(book).size()); + } + + @Test + @Order(3) + void testFindByAuthor() { + List bookAuthors = bookAuthorRepository.findAll(); + assertEquals(1, bookAuthors.size()); + Author author = bookAuthors.get(0).getAuthor(); + assertEquals(1, bookAuthorRepository.findByAuthor(author).size()); + } + + @Test + @Order(4) + void deleteBookAuthor() { + BookAuthor bookAuthor = bookAuthorRepository.findAll().get(0); + Author author = bookAuthor.getAuthor(); + author.getBookAuthors().remove(bookAuthor); + author = authorRepository.save(author); + Book book = bookAuthor.getBook(); + book.getAuthors().remove(bookAuthor); + BookshelfPublisher publisher = book.getPublisher(); + publisher.getBooks().remove(book); + bookshelfPublisherRepository.save(publisher); + book = bookRepository.save(book); + bookshelfPublisherRepository.delete(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT, bookshelfPublisherRepository.findAll().size()); + bookAuthorRepository.delete(bookAuthor); + assertEquals(TestConstants.BOOKAUTHOR_COUNT, bookAuthorRepository.findAll().size()); + authorRepository.delete(author); + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + bookRepository.delete(book); + assertEquals(TestConstants.BOOK_COUNT, bookRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorTest.java new file mode 100644 index 0000000..dc79341 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookAuthorTest.java @@ -0,0 +1,82 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@Disabled +class BookAuthorTest { + + @Autowired + BookRepository bookRepository; + + @Autowired + AuthorRepository authorRepository; + + @Autowired + BookAuthorRepository bookAuthorRepository; + + @Autowired + BookshelfPublisherRepository bookshelfPublisherRepository; + + @Test + void checkInitialLoad() { + assertEquals(TestConstants.BOOKAUTHOR_COUNT, bookAuthorRepository.findAll().size()); + } + + @Test + void exceptionThrownWhenSavingEmptyValues() { + BookAuthor bookAuthor = new BookAuthor(); + assertThrows(TransactionSystemException.class, () -> { + bookAuthorRepository.save(bookAuthor); + }); + } + + @Test + void exceptionThrownWhenSavingIdenticalEntry() { + Author author = new Author(); + author.setFirstName(TestConstants.AUTHOR_FIRSTNAME); + author.setLastName(TestConstants.AUTHOR_LASTNAME); + Author savedAuthor = authorRepository.save(author); + BookshelfPublisher publisher = new BookshelfPublisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + BookshelfPublisher savedPublisher = bookshelfPublisherRepository.save(publisher); + Book book = new Book(); + book.setTitle(TestConstants.BOOK_TITLE); + book.setIsbn(TestConstants.BOOK_ISBN); + book.setPublisher(publisher); + Book savedBook = bookRepository.save(book); + BookAuthor bookAuthor = new BookAuthor(); + bookAuthor.setBook(book); + bookAuthor.setAuthor(author); + BookAuthor savedBookAuthor = bookAuthorRepository.save(bookAuthor); + BookAuthor bookAuthor1 = new BookAuthor(); + bookAuthor1.setBook(book); + bookAuthor1.setAuthor(author); + assertThrows(DataIntegrityViolationException.class, () -> { + bookAuthorRepository.save(bookAuthor1); + }); + savedAuthor.getBookAuthors().remove(savedBookAuthor); + authorRepository.save(savedAuthor); + savedBook.getAuthors().remove(savedBookAuthor); + bookRepository.save(savedBook); + savedPublisher.getBooks().remove(savedBook); + bookshelfPublisherRepository.save(savedPublisher); + authorRepository.delete(savedAuthor); + assertEquals(TestConstants.AUTHOR_COUNT, authorRepository.findAll().size()); + bookRepository.delete(savedBook); + assertEquals(TestConstants.ARTICLE_COUNT, bookRepository.findAll().size()); + bookshelfPublisherRepository.delete(savedPublisher); + assertEquals(TestConstants.PUBLISHER_COUNT, bookshelfPublisherRepository.findAll().size()); + bookAuthorRepository.delete(savedBookAuthor); + assertEquals(TestConstants.ARTICLEAUTHOR_COUNT, bookAuthorRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookRepositoryTest.java new file mode 100644 index 0000000..969d465 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookRepositoryTest.java @@ -0,0 +1,87 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Disabled +class BookRepositoryTest { + + @Autowired + BookRepository bookRepository; + + @Autowired + BookshelfPublisherRepository bookshelfPublisherRepository; + + @Test + @Order(1) + void checkInitialLoad(){ + assertEquals(TestConstants.BOOK_COUNT, bookRepository.findAll().size()); + } + + @Test + @Order(2) + void saveBook() { + BookshelfPublisher publisher = new BookshelfPublisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + bookshelfPublisherRepository.save(publisher); + Book book = new Book(); + book.setTitle(TestConstants.BOOK_TITLE); + book.setIsbn(TestConstants.BOOK_ISBN); + book.setPublisher(publisher); + bookRepository.save(book); + assertEquals(TestConstants.BOOK_COUNT+1, bookRepository.findAll().size()); + } + + @Test + @Order(3) + void search() { + List books = bookRepository.search(TestConstants.BOOK_TITLE.substring(2,5)); + assertEquals(1, books.size()); + assertEquals(1, bookRepository.search(TestConstants.BOOK_ISBN.substring(3,7)).size()); + assertEquals(0, bookRepository.search("Article").size()); + } + + @Test + @Order(4) + void findByTitle() { + List books = bookRepository.findByTitle(TestConstants.BOOK_TITLE); + assertEquals(1, books.size()); + assertEquals(TestConstants.BOOK_ISBN, books.get(0).getIsbn()); + } + + @Test + @Order(5) + void findByTitleIgnoreCase() { + List books = bookRepository.findByTitleIgnoreCase(TestConstants.BOOK_TITLE.toLowerCase()); + assertEquals(1, books.size()); + assertEquals(TestConstants.BOOK_ISBN, books.get(0).getIsbn()); + } + + @Test + @Order(6) + void findByIsbn() { + List books = bookRepository.findByIsbn(TestConstants.BOOK_ISBN); + assertEquals(1, books.size()); + assertEquals(TestConstants.BOOK_TITLE, books.get(0).getTitle()); + } + + @Test + @Order(7) + void deleteBook() { + List books = bookRepository.findByIsbn(TestConstants.BOOK_ISBN); + Book book = books.get(0); + BookshelfPublisher publisher = book.getPublisher(); + bookshelfPublisherRepository.delete(publisher); + bookRepository.delete(book); + assertEquals(TestConstants.BOOK_COUNT, bookRepository.findAll().size()); + assertEquals(TestConstants.PUBLISHER_COUNT, bookshelfPublisherRepository.findAll().size()); + } +} \ No newline at end of file diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookTest.java new file mode 100644 index 0000000..0ac3324 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookTest.java @@ -0,0 +1,56 @@ +package de.thpeetz.kontor.bookshelf.data; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import de.thpeetz.kontor.comics.data.Publisher; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.transaction.TransactionSystemException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class BookTest { + + @Autowired + BookRepository bookRepository; + + @Autowired + BookshelfPublisherRepository bookshelfPublisherRepository; + + @Test + void checkInitialLoad() { + assertEquals(TestConstants.BOOK_COUNT, bookRepository.findAll().size()); + } + + @Test + void exceptionThrownWhenSavingEmptyFields() { + Book book = new Book(); + assertThrows(TransactionSystemException.class, () -> { + bookRepository.save(book); + }); + } + + @Test + void exceptionThrownWhenSavingIdenticalISBN() { + BookshelfPublisher publisher = new BookshelfPublisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + BookshelfPublisher savedPublisher = bookshelfPublisherRepository.save(publisher); + Book book = new Book(); + book.setTitle(TestConstants.BOOK_TITLE); + book.setIsbn(TestConstants.BOOK_ISBN); + book.setPublisher(savedPublisher); + Book savedBook = bookRepository.save(book); + Book book1 = new Book(); + book1.setTitle(book.getTitle()); + book1.setIsbn(book.getIsbn()); + assertThrows(TransactionSystemException.class, ()-> { + bookRepository.save(book1); + }); + bookRepository.delete(book); + assertEquals(TestConstants.BOOK_COUNT, bookRepository.findAll().size()); + bookshelfPublisherRepository.delete(savedPublisher); + assertEquals(TestConstants.PUBLISHER_COUNT, bookshelfPublisherRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepositoryTest.java new file mode 100644 index 0000000..98b60fa --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherRepositoryTest.java @@ -0,0 +1,72 @@ +package de.thpeetz.kontor.bookshelf.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class BookshelfPublisherRepositoryTest { + + @Autowired + private BookshelfPublisherRepository publisherRepository; + + @Test + @Order(1) + void checkInitialLoad() { + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } + + @Test + @Order(2) + void savePublisher() { + BookshelfPublisher publisher = new BookshelfPublisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + publisherRepository.save(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT + 1, publisherRepository.findAll().size()); + } + + @Test + @Order(3) + void findPublisherByName() { + assertEquals(TestConstants.PUBLISHER_COUNT + 1, publisherRepository.findAll().size()); + log.info("Liste der Publisher: {}", publisherRepository.findAll()); + BookshelfPublisher publisher = publisherRepository.findByName(TestConstants.PUBLISHER_NAME); + assertNotNull(publisher); + assertEquals(TestConstants.PUBLISHER_NAME, publisher.getName()); + BookshelfPublisher notFound = publisherRepository.findByName(TestConstants.PUBLISHER_SEARCH); + assertNull(notFound); + List publishers = publisherRepository + .findByNameIgnoreCase(TestConstants.PUBLISHER_NAME.toLowerCase()); + assertEquals(1, publishers.size()); + assertEquals(TestConstants.PUBLISHER_NAME, publishers.get(0).getName()); + } + + @Test + @Order(4) + void searchPublisher() { + List publishers = publisherRepository.search(TestConstants.PUBLISHER_NAME.substring(2, 6)); + assertEquals(1, publishers.size()); + assertEquals(TestConstants.PUBLISHER_NAME, publishers.get(0).getName()); + } + + @Test + @Order(5) + void deletePublisher() { + BookshelfPublisher publisher = publisherRepository.findByName(TestConstants.PUBLISHER_NAME); + assertNotNull(publisher); + publisherRepository.delete(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherTest.java new file mode 100644 index 0000000..7a73ae1 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/data/BookshelfPublisherTest.java @@ -0,0 +1,45 @@ +package de.thpeetz.kontor.bookshelf.data; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.bookshelf.TestConstants; + +@SpringBootTest +class BookshelfPublisherTest { + + @Autowired + private BookshelfPublisherRepository publisherRepository; + + @Test + void checkInitialDataLoad() { + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } + + @Test + void throwExceptionWhenPublisherSavedWithEmptyName() { + BookshelfPublisher publisher = new BookshelfPublisher(); + assertThrows(TransactionSystemException.class, () -> { + publisherRepository.save(publisher); + }); + } + + @Test + void savePublisherWithIdenticalName() { + BookshelfPublisher publisher1 = new BookshelfPublisher(); + publisher1.setName(TestConstants.PUBLISHER_NAME); + publisherRepository.save(publisher1); + BookshelfPublisher publisher2 = new BookshelfPublisher(); + publisher2.setName(TestConstants.PUBLISHER_NAME); + assertThrows(DataIntegrityViolationException.class, () -> { + publisherRepository.save(publisher2); + }); + publisherRepository.delete(publisher1); + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/bookshelf/services/BookshelfServiceTest.java b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/services/BookshelfServiceTest.java new file mode 100644 index 0000000..b78a899 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/bookshelf/services/BookshelfServiceTest.java @@ -0,0 +1,102 @@ +package de.thpeetz.kontor.bookshelf.services; + +import de.thpeetz.kontor.bookshelf.TestConstants; +import de.thpeetz.kontor.bookshelf.data.Author; +import de.thpeetz.kontor.bookshelf.data.Book; +import de.thpeetz.kontor.bookshelf.data.BookshelfPublisher; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class BookshelfServiceTest { + + @Autowired + private BookshelfService bookshelfService; + + @Test + @Order(1) + void testFindAllPublishers() { + List publishers = bookshelfService.findAllPublishers(null); + assertEquals(TestConstants.PUBLISHER_COUNT, publishers.size()); + } + + @Test + @Order(2) + void testSavePublisher() { + BookshelfPublisher publisher = new BookshelfPublisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + bookshelfService.savePublisher(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT + 1, bookshelfService.findAllPublishers(null).size()); + } + + @Test + @Order(3) + void testFindPublisherByName() { + assertEquals(TestConstants.PUBLISHER_COUNT + 1, bookshelfService.findAllPublishers(null).size()); + BookshelfPublisher publisher = bookshelfService.findPublisherByName(TestConstants.PUBLISHER_NAME); + assertNotNull(publisher); + } + + @Test + @Order(4) + void testDeletePublisher() { + List publishers = bookshelfService.findAllPublishers(TestConstants.PUBLISHER_NAME); + assertEquals(1, publishers.size()); + bookshelfService.deletePublisher(publishers.get(0)); + assertEquals(TestConstants.PUBLISHER_COUNT, bookshelfService.findAllPublishers(null).size()); + } + + @Test + @Order(5) + void testFindAllAuthors() { + assertEquals(TestConstants.AUTHOR_COUNT, bookshelfService.findAllAuthors(null).size()); + } + + @Test + @Order(6) + void testSaveAuthor() { + Author author = new Author(); + author.setFirstName(TestConstants.AUTHOR_FIRSTNAME); + author.setLastName(TestConstants.AUTHOR_LASTNAME); + bookshelfService.saveAuthor(author); + assertEquals(TestConstants.AUTHOR_COUNT+1, bookshelfService.findAllAuthors(null).size()); + } + + @Test + @Order(7) + void testDeleteAuthor() { + List authors = bookshelfService.findAllAuthors(TestConstants.AUTHOR_FIRSTNAME); + assertEquals(1, authors.size()); + bookshelfService.deleteAuthor(authors.get(0)); + assertEquals(TestConstants.AUTHOR_COUNT, bookshelfService.findAllAuthors(null).size()); + } + + @Test + @Order(8) + void findAllBooks() { + assertEquals(TestConstants.BOOK_COUNT, bookshelfService.findAllBooks(null).size()); + } + + @Test + @Order(9) + void saveBook() { + assertEquals(TestConstants.BOOK_COUNT, bookshelfService.findAllBooks(null).size()); + } + + @Test + @Order(10) + void deleteBook() { + List books = bookshelfService.findAllBooks(null); + assertEquals(0, books.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/ComicConstantsTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/ComicConstantsTest.java new file mode 100644 index 0000000..b1e50ce --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/ComicConstantsTest.java @@ -0,0 +1,14 @@ +package de.thpeetz.kontor.comics; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class ComicConstantsTest { + + @Test + void getArtistConstants() { + assertEquals("Artist", ComicConstants.ARTIST); + assertEquals("comics/artist", ComicConstants.ARTIST_ROUTE); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/TestConstants.java b/springboot/src/test/java/de/thpeetz/kontor/comics/TestConstants.java new file mode 100644 index 0000000..b77f55d --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/TestConstants.java @@ -0,0 +1,67 @@ +package de.thpeetz.kontor.comics; + +import de.thpeetz.kontor.comics.data.Artist; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.Publisher; +import de.thpeetz.kontor.comics.data.Worktype; +import de.thpeetz.kontor.comics.services.ComicService; + +public class TestConstants { + public static final String ARTIST_NAME = "Lastname, Firstname"; + public static final String COMIC_TITLE = "TestComic"; + public static final String WORKTYPE_INKER = "Inker"; + public static final String ISSUE_ISSUENUMBER = "Issuenumber"; + public static final String PUBLISHER_NAME = "Publisher"; + public static final String SOJOURN_TPB_COMIC_TITLE = "Sojourn"; + public static final String SOJOURN_TPB_NAME = "The Dragons Tale"; + public static final String STORYARC_NAME = "StoryArc"; + public static final String TRADEPAPERBACK_NAME = "TradePaperback"; + public static final String VOLUME_NAME = "Volume"; + public static final String WORKTYPE_NAME = "Worktype"; + public static final int ARTIST_COUNT = 5; + public static final int BATTLE_POPE_ISSUE_COUNT = 12; + public static final int COMIC_COUNT = 169; + public static final int COMICWORK_COUNT = 18; + public static final int ISSUE_COUNT = 750; + public static final int MARVEL_COMIC_COUNT = 50; + public static final int PUBLISHER_COUNT = 18; + public static final int SOJOURN_TPB_COUNT = 4; + public static final int SOJOURN_TPB_START = 7; + public static final int SOJOURN_TPB_END = 12; + public static final int STORYARC_COUNT = 3; + public static final int TRADEPAPERBACK_COUNT = 40; + public static final int VOLUME_COUNT = 0; + public static final int WORKTYPE_COUNT = 3; + + public static Comic getComicWithStoryArcs(ComicService service) { + return service.findComicByTitle("Emma Frost"); + } + + public static Comic getComicWithTradePaperbacks(ComicService service) { + return service.findComicByTitle(SOJOURN_TPB_COMIC_TITLE); + } + + public static Comic getComicWithIssues(ComicService service) { + return service.findComicByTitle("Battle Pope"); + } + + public static Comic getComicWithoutReferences(ComicService service) { + return service.findComicByTitle("Gen13"); + } + + public static Artist getArtistWithoutReferences(ComicService service) { + return service.findArtistByName("Marz, Ron"); + } + + public static Worktype getWorktypeWithoutReferences(ComicService service) { + return service.findWorktypeByName(WORKTYPE_INKER); + } + + public static Publisher getPublisherForComicTests(ComicService service) { + return service.findPublisherByName("Marvel"); + } + + public static Publisher getAnotherPublisherForComicTests(ComicService service) { + return service.findPublisherByName("DC"); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistRepositoryTest.java new file mode 100644 index 0000000..96d53f8 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistRepositoryTest.java @@ -0,0 +1,73 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class ArtistRepositoryTest { + + private Artist stanLee; + private static final String ARTISTNAME = "Lee, Stan"; + + @Autowired + ArtistRepository artistRepository; + + @BeforeEach + void setupData() { + stanLee = new Artist(); + stanLee.setName(ARTISTNAME); + } + + @Test + @Order(1) + void checkInitialLoad() { + int count = artistRepository.findAll().size(); + assertEquals(5, count); + } + + @Test + @Order(2) + void saveArtist() { + int count = artistRepository.findAll().size(); + artistRepository.save(stanLee); + assertEquals(count+1, artistRepository.findAll().size()); + } + + @Test + @Order(3) + void findArtist() { + List artists = artistRepository.findByNameIgnoreCase("lEE, sTAN"); + assertTrue(artists.size() > 0); + assertEquals(artists.get(0).getName(), stanLee.getName()); + } + + @Test + @Order(4) + void searchArtist() { + List artists = artistRepository.search("Lee"); + assertEquals(1, artists.size()); + assertEquals(ARTISTNAME, artists.get(0).getName()); + List artists2 = artistRepository.search("Stan"); + assertEquals(1, artists2.size()); + assertEquals(ARTISTNAME, artists2.get(0).getName()); + } + + @Test + @Order(5) + void deleteArtist() { + int count = artistRepository.findAll().size(); + Artist artist = artistRepository.findByName(ARTISTNAME); + artistRepository.delete(artist); + assertEquals(count-1, artistRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistTest.java new file mode 100644 index 0000000..855795e --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ArtistTest.java @@ -0,0 +1,48 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; + +@SpringBootTest +class ArtistTest { + + @Autowired + private ArtistRepository artistRepository; + + @Test + void checkInitialDataLoad() { + List artists = artistRepository.findAll(); + assertEquals(TestConstants.ARTIST_COUNT, artists.size()); + } + + @Test + void throwExceptionWhenArtistSavedWithEmptyName() { + Artist artist1 = new Artist(); + assertThrows(TransactionSystemException.class, () -> { + artistRepository.save(artist1); + }); + } + + @Test + void saveArtistWithIdenticalName() { + String artistName = "Lastname, Firstname"; + Artist artist1 = new Artist(); + artist1.setName(artistName); + artistRepository.save(artist1); + Artist artist2 = new Artist(); + artist2.setName(artistName); + assertThrows(DataIntegrityViolationException.class, () -> { + artistRepository.save(artist2); + }); + artistRepository.delete(artist1); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicRepositoryTest.java new file mode 100644 index 0000000..70c0d54 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicRepositoryTest.java @@ -0,0 +1,53 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ComicRepositoryTest { + + @Autowired + private ComicRepository comicRepository; + + @Autowired + private PublisherRepository publisherRepository; + + @Test + void testFindByTitle() { + List comics = comicRepository.findByTitle("Emma Frost"); + assertEquals(1, comics.size()); + assertEquals("Emma Frost", comics.get(0).getTitle()); + Publisher publisher = comics.get(0).getPublisher(); + assertEquals("Marvel", publisher.getName()); + } + + @Test + void testFindByTitleIgnoreCase() { + List comics = comicRepository.findByTitleIgnoreCase("x-men"); + assertEquals(1, comics.size()); + assertEquals("X-Men", comics.get(0).getTitle()); + } + @Test + void testFindByTitleAndPublisher() { + Publisher publisher = publisherRepository.findByName("Marvel"); + assertNotNull(publisher); + Comic found = comicRepository.findByTitleAndPublisher("Emma Frost", publisher); + assertNotNull(found); + assertEquals("Emma Frost", found.getTitle()); + assertEquals("Marvel", found.getPublisher().getName()); + + } + + @Test + void testSearch() { + List comics = comicRepository.search("X-men"); + assertEquals(11, comics.size()); + assertTrue(comics.stream().map(comic -> comic.getTitle()).collect(Collectors.toList()).contains("Astonishing X-Men")); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicTest.java new file mode 100644 index 0000000..5560346 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicTest.java @@ -0,0 +1,77 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class ComicTest { + + @Autowired + private ComicRepository comicRepository; + + @Autowired + private ComicService comicService; + + @Test + @Order(1) + void checkInitialDataLoad() { + List comics = comicRepository.findAll(); + assertEquals(TestConstants.COMIC_COUNT, comics.size()); + } + + @Test + @Order(2) + void exceptionThrownWhenSavingComicWithoutPublisher() { + Comic comic = new Comic(); + comic.setTitle(TestConstants.COMIC_TITLE); + assertThrows(TransactionSystemException.class, () -> { + comicRepository.save(comic); + }); + } + + @Test + @Order(3) + void exceptionThrownWhenSavingComicWithEmptyTitle() { + Comic comic = new Comic(); + Publisher publisher = TestConstants.getPublisherForComicTests(comicService); + comic.setPublisher(publisher); + assertNull(comic.getTitle()); + assertThrows(TransactionSystemException.class, () -> { + comicRepository.save(comic); + }); + comic.setTitle(""); + assertTrue(comic.getTitle().isEmpty()); + assertThrows(TransactionSystemException.class, () -> { + comicRepository.save(comic); + }); + } + + @Test + @Order(4) + void exceptionThrownWhenSavingComicWithSameNameFromDifferentPublishers() { + Publisher publisher = TestConstants.getPublisherForComicTests(comicService); + Comic comic = new Comic(); + comic.setTitle(TestConstants.SOJOURN_TPB_COMIC_TITLE); + comic.setPublisher(publisher); + assertThrows(DataIntegrityViolationException.class, () -> { + comicRepository.save(comic); + }); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkRepositoryTest.java new file mode 100644 index 0000000..0902b84 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkRepositoryTest.java @@ -0,0 +1,83 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class ComicWorkRepositoryTest { + + @Autowired + private ComicWorkRepository comicWorkRepository; + + @Autowired + private ComicRepository comicRepository; + + @Autowired + private WorktypeRepository worktypeRepository; + + @Autowired + private ArtistRepository artistRepository; + + @Autowired + private ComicService comicService; + + @Test + @Order(1) + void checkInitialLoad() { + int count = comicWorkRepository.findAll().size(); + assertEquals(18, count); + } + + @Test + @Order(2) + void saveComicWork() { + int count = comicWorkRepository.findAll().size(); + Artist artist = artistRepository.findByName("Turner, Michael"); + Worktype worktype = worktypeRepository.findByName("Writer"); + Comic comic = comicRepository.findByTitle("Emma Frost").get(0); + ComicWork comicWork = new ComicWork(); + comicWork.setArtist(artist); + comicWork.setComic(comic); + comicWork.setWorkType(worktype); + comicWorkRepository.save(comicWork); + assertEquals(count + 1, comicWorkRepository.findAll().size()); + } + + @Test + @Order(3) + void findByComicAndArtistAndComicWork() { + assertEquals(19, comicWorkRepository.count()); + Artist artist = artistRepository.findByName("Turner, Michael"); + Worktype worktype = worktypeRepository.findByName("Writer"); + Comic comic = comicRepository.findByTitle("Emma Frost").get(0); + ComicWork comicWork = comicWorkRepository.findbyComicAndArtistAndWorktype(comic, artist, worktype); + assertNotNull(comicWork); + assertEquals(comicWork.getArtist().getName(), artist.getName()); + assertEquals(comicWork.getComic().getTitle(), comic.getTitle()); + assertEquals(comicWork.getWorkType().getName(), worktype.getName()); + ComicWork notFound = comicWorkRepository.findbyComicAndArtistAndWorktype(comic, artist, null); + assertNull(notFound); + } + + @Test + @Order(4) + void deleteComicWork() { + long count = comicWorkRepository.count(); + Artist artist = artistRepository.findByName("Turner, Michael"); + Worktype worktype = worktypeRepository.findByName("Writer"); + Comic comic = comicRepository.findByTitle("Emma Frost").get(0); + ComicWork comicWork = comicWorkRepository.findbyComicAndArtistAndWorktype(comic, artist, worktype); + assertNotNull(comicWork); + comicService.deleteComicWork(comicWork); + assertEquals(count - 1, comicWorkRepository.count()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkTest.java new file mode 100644 index 0000000..ac2eac2 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/ComicWorkTest.java @@ -0,0 +1,24 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +public class ComicWorkTest { + + @Autowired + private ComicService comicService; + + @Test + void checkInitialDataLoad() { + List comicWorks = comicService.findAllComicWorks(); + assertEquals(18, comicWorks.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueRepositoryTest.java new file mode 100644 index 0000000..8d3be93 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueRepositoryTest.java @@ -0,0 +1,45 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class IssueRepositoryTest { + + @Autowired + private IssueRepository issueRepository; + + @Autowired + private ComicService comicService; + + @Test + void testFindByComic() { + Comic comic = TestConstants.getComicWithIssues(comicService); + List issues = issueRepository.findByComic(comic); + assertEquals(TestConstants.BATTLE_POPE_ISSUE_COUNT, issues.size()); + } + + @Test + void testFindByComicAndIssueNumber() { + Comic comic = TestConstants.getComicWithIssues(comicService); + Issue issue = issueRepository.findByComicAndIssueNumber(comic, "12"); + assertNotNull(issue); + assertFalse(issue.getIsRead()); + } + + @Test + void testSearch() { + List issues = issueRepository.search("2"); + assertEquals(187, issues.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueTest.java new file mode 100644 index 0000000..1760fd1 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/IssueTest.java @@ -0,0 +1,62 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class IssueTest { + + @Autowired + private IssueRepository issueRepository; + + @Autowired + private ComicService comicService; + + @Test + void checkInitialDataLoad() { + List issues = comicService.findAllIssues(); + assertEquals(TestConstants.ISSUE_COUNT, issues.size()); + } + + @Test + void exceptionThrownWhenSavingIssueWithEmptyNumber() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertNotNull(comic); + Issue issue = new Issue(); + issue.setComic(comic); + assertThrows(TransactionSystemException.class, () -> { + issueRepository.save(issue); + }); + } + + @Test + void exceptionThrownWhenSavingStoryArcWithoutComic() { + Issue issue = new Issue(); + issue.setIssueNumber(TestConstants.ISSUE_ISSUENUMBER); + assertThrows(TransactionSystemException.class, () -> { + issueRepository.save(issue); + }); + } + + @Test + void saveStoryArc() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertEquals(0, comic.getStoryArcs().size()); + Issue issue = new Issue(); + issue.setComic(comic); + issue.setIssueNumber(TestConstants.ISSUE_ISSUENUMBER); + Issue savedInstance = issueRepository.save(issue); + assertNotNull(savedInstance); + comicService.deleteIssue(savedInstance); + assertEquals(0, comic.getStoryArcs().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherRepositoryTest.java new file mode 100644 index 0000000..50175b4 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherRepositoryTest.java @@ -0,0 +1,68 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.TestConstants; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class PublisherRepositoryTest { + + @Autowired + private PublisherRepository publisherRepository; + + @Test + @Order(1) + void checkInitialLoad() { + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } + + @Test + @Order(2) + void savePublisher() { + Publisher publisher = new Publisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + publisherRepository.save(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT + 1, publisherRepository.findAll().size()); + } + + @Test + @Order(3) + void findPublisherByName() { + Publisher publisher = publisherRepository.findByName(TestConstants.PUBLISHER_NAME); + assertNotNull(publisher); + assertEquals(TestConstants.PUBLISHER_NAME, publisher.getName()); + Publisher notFound = publisherRepository.findByName("Cow"); + assertNull(notFound); + List publishers = publisherRepository + .findByNameIgnoreCase(TestConstants.PUBLISHER_NAME.toLowerCase()); + assertEquals(1, publishers.size()); + assertEquals(TestConstants.PUBLISHER_NAME, publishers.get(0).getName()); + } + + @Test + @Order(4) + void searchPublisher() { + List publishers = publisherRepository.search("Cow"); + assertEquals(1, publishers.size()); + assertEquals("Top Cow Productions", publishers.get(0).getName()); + } + + @Test + @Order(5) + void deletePublisher() { + Publisher publisher = publisherRepository.findByName(TestConstants.PUBLISHER_NAME); + assertNotNull(publisher); + publisherRepository.delete(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherTest.java new file mode 100644 index 0000000..264a1d0 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/PublisherTest.java @@ -0,0 +1,45 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; + +@SpringBootTest +class PublisherTest { + + @Autowired + private PublisherRepository publisherRepository; + + @Test + void checkInitialDataLoad() { + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } + + @Test + void throwExceptionWhenPublisherSavedWithEmptyName() { + Publisher publisher = new Publisher(); + assertThrows(TransactionSystemException.class, () -> { + publisherRepository.save(publisher); + }); + } + + @Test + void savePublisherWithIdenticalName() { + Publisher publisher1 = new Publisher(); + publisher1.setName(TestConstants.PUBLISHER_NAME); + publisherRepository.save(publisher1); + Publisher publisher2 = new Publisher(); + publisher2.setName(TestConstants.PUBLISHER_NAME); + assertThrows(DataIntegrityViolationException.class, () -> { + publisherRepository.save(publisher2); + }); + publisherRepository.delete(publisher1); + assertEquals(TestConstants.PUBLISHER_COUNT, publisherRepository.findAll().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcRepositoryTest.java new file mode 100644 index 0000000..52bad7e --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcRepositoryTest.java @@ -0,0 +1,45 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class StoryArcRepositoryTest { + + @Autowired + private StoryArcRepository storyArcRepository; + + @Autowired + private ComicService comicService; + + @Test + void testSearch() { + List storyArcs = storyArcRepository.search("Learn"); + assertNotNull(storyArcs); + assertEquals(1, storyArcs.size()); + } + + @Test + void findByComic() { + Comic comic = TestConstants.getComicWithStoryArcs(comicService); + List storyArcs = storyArcRepository.findByComic(comic); + assertNotNull(storyArcs); + assertEquals(3, storyArcs.size()); + } + + @Test + void findByNameAndComic() { + Comic comic = TestConstants.getComicWithStoryArcs(comicService); + StoryArc storyArc = storyArcRepository.findByNameAndComic("Higher Learning", comic); + assertNotNull(storyArc); + assertEquals("Emma Frost", storyArc.getComic().getTitle()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcTest.java new file mode 100644 index 0000000..07134ca --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/StoryArcTest.java @@ -0,0 +1,61 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class StoryArcTest { + + @Autowired + private StoryArcRepository storyArcRepository; + + @Autowired + private ComicService comicService; + + @Test + void checkInitialLoad() { + assertEquals(TestConstants.STORYARC_COUNT, storyArcRepository.findAll().size()); + } + + @Test + void exceptionThrownWhenSavingStoryArcWithEmptyName() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertNotNull(comic); + StoryArc storyArc = new StoryArc(); + storyArc.setComic(comic); + assertThrows(TransactionSystemException.class, () -> { + storyArcRepository.save(storyArc); + }); + } + + @Test + void exceptionThrownWhenSavingStoryArcWithoutComic() { + StoryArc storyArc = new StoryArc(); + storyArc.setName(TestConstants.STORYARC_NAME); + assertThrows(TransactionSystemException.class, () -> { + storyArcRepository.save(storyArc); + }); + } + + @Test + void saveStoryArc() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertEquals(0, comic.getStoryArcs().size()); + StoryArc storyArc = new StoryArc(); + storyArc.setName(TestConstants.STORYARC_NAME); + storyArc.setComic(comic); + StoryArc savedInstance = storyArcRepository.save(storyArc); + assertNotNull(savedInstance); + comicService.deleteStoryArc(savedInstance); + assertEquals(0, comic.getStoryArcs().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackRepositoryTest.java new file mode 100644 index 0000000..2c66c12 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackRepositoryTest.java @@ -0,0 +1,59 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class TradePaperbackRepositoryTest { + + @Autowired + private TradePaperbackRepository tradePaperbackRepository; + + @Autowired + private ComicService comicService; + + @Test + void testSearch() { + List tradePaperbacks = tradePaperbackRepository.search("Dragon"); + assertNotNull(tradePaperbacks); + assertEquals(1, tradePaperbacks.size()); + } + + @Test + void testFindByComic() { + Comic comic = TestConstants.getComicWithTradePaperbacks(comicService); + List tradePaperbacks = tradePaperbackRepository.findByComic(comic); + assertNotNull(tradePaperbacks); + assertEquals(TestConstants.SOJOURN_TPB_COUNT, tradePaperbacks.size()); + } + + @Test + void testFindByNameAndComic() { + Comic comic = TestConstants.getComicWithTradePaperbacks(comicService); + List tradePaperbacks = tradePaperbackRepository + .findByNameAndComic(TestConstants.SOJOURN_TPB_NAME, comic); + assertNotNull(tradePaperbacks); + assertTrue(tradePaperbacks.size() > 0); + assertEquals(TestConstants.SOJOURN_TPB_COMIC_TITLE, tradePaperbacks.get(0).getComic().getTitle()); + } + + @Test + void testFindByFields() { + Comic comic = TestConstants.getComicWithTradePaperbacks(comicService); + TradePaperback tradePaperback = tradePaperbackRepository.findByFields(TestConstants.SOJOURN_TPB_NAME, + comic, TestConstants.SOJOURN_TPB_START, TestConstants.SOJOURN_TPB_END); + assertNotNull(tradePaperback); + assertEquals(TestConstants.SOJOURN_TPB_COMIC_TITLE, tradePaperback.getComic().getTitle()); + assertEquals(TestConstants.SOJOURN_TPB_END, tradePaperback.getIssueEnd()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackTest.java new file mode 100644 index 0000000..61df1d6 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/TradePaperbackTest.java @@ -0,0 +1,59 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class TradePaperbackTest { + + @Autowired + private TradePaperbackRepository tradePaperbackRepository; + + @Autowired + private ComicService comicService; + + @Test + void checkInitialDataLoad() { + assertEquals(TestConstants.TRADEPAPERBACK_COUNT, tradePaperbackRepository.findAll().size()); + } + + @Test + void exceptionThrownWhenSavingTradePaperbackWithEmptyName() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertNotNull(comic); + TradePaperback tradePaperback = new TradePaperback(); + tradePaperback.setComic(comic); + assertThrows(TransactionSystemException.class, () -> { + tradePaperbackRepository.save(tradePaperback); + }); + } + + @Test + void exceptionThrownWhenSavingTradePaperbackWithoutComic() { + TradePaperback tradePaperback = new TradePaperback(); + tradePaperback.setName(TestConstants.TRADEPAPERBACK_NAME); + assertThrows(TransactionSystemException.class, () -> { + tradePaperbackRepository.save(tradePaperback); + }); + } + + @Test + void saveTradePaperback() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertEquals(0, comic.getTradePaperbacks().size()); + TradePaperback tradePaperback = new TradePaperback(); + tradePaperback.setName(TestConstants.TRADEPAPERBACK_NAME); + tradePaperback.setComic(comic); + TradePaperback savedInstance = tradePaperbackRepository.save(tradePaperback); + assertNotNull(savedInstance); + comicService.deleteTradePaperBack(savedInstance); + assertEquals(0, comic.getTradePaperbacks().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeRepositoryTest.java new file mode 100644 index 0000000..ce04a11 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeRepositoryTest.java @@ -0,0 +1,61 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class VolumeRepositoryTest { + + @Autowired + private VolumeRepository volumeRepository; + + @Autowired + private ComicRepository comicRepository; + + @Autowired + private ComicService comicService; + + @Test + void testFindByComic() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertEquals(0, comic.getVolumes().size()); + Volume volume = new Volume(); + volume.setName(TestConstants.VOLUME_NAME); + volume.setComic(comic); + Volume savedInstance = volumeRepository.save(volume); + assertNotNull(savedInstance); + + List found = volumeRepository.findByComic(comic); + assertEquals(1, found.size()); + + comicService.deleteVolume(found.get(0)); + assertEquals(0, comic.getVolumes().size()); + assertEquals(TestConstants.VOLUME_COUNT, volumeRepository.count()); + } + + @Test + void testFindByName() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertEquals(0, comic.getVolumes().size()); + Volume volume = new Volume(); + volume.setName(TestConstants.VOLUME_NAME); + volume.setComic(comic); + Volume savedInstance = volumeRepository.save(volume); + assertNotNull(savedInstance); + + List found = volumeRepository.findByName(TestConstants.VOLUME_NAME); + assertEquals(1, found.size()); + + comicService.deleteVolume(found.get(0)); + assertEquals(0, comic.getVolumes().size()); + assertEquals(TestConstants.VOLUME_COUNT, volumeRepository.count()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeTest.java new file mode 100644 index 0000000..7189965 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/VolumeTest.java @@ -0,0 +1,64 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.services.ComicService; + +@SpringBootTest +class VolumeTest { + + @Autowired + private VolumeRepository volumeRepository; + + @Autowired + private ComicRepository comicRepository; + + @Autowired + private ComicService comicService; + + @Test + void checkInitialDataLoad() { + assertEquals(TestConstants.VOLUME_COUNT, volumeRepository.count()); + } + + @Test + void exceptionThrownWhenSavingVolumeWithEmptyName() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + Volume volume = new Volume(); + volume.setComic(comic); + assertThrows(TransactionSystemException.class, () -> { + volumeRepository.save(volume); + }); + } + + @Test + void exceptionThrownWhenSavingVolumeWithoutComic() { + Volume volume = new Volume(); + volume.setName(TestConstants.VOLUME_NAME); + assertThrows(TransactionSystemException.class, () -> { + volumeRepository.save(volume); + }); + } + + @Test + void saveVolume() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + assertEquals(0, comic.getVolumes().size()); + Volume volume = new Volume(); + volume.setName(TestConstants.VOLUME_NAME); + volume.setComic(comic); + Volume savedInstance = volumeRepository.save(volume); + assertNotNull(savedInstance); + comicService.deleteVolume(savedInstance); + assertEquals(0, comic.getVolumes().size()); + assertEquals(TestConstants.VOLUME_COUNT, volumeRepository.count()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeRepositoryTest.java new file mode 100644 index 0000000..5dae5e8 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeRepositoryTest.java @@ -0,0 +1,46 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.TestConstants; + +@SpringBootTest +class WorktypeRepositoryTest { + + @Autowired + private WorktypeRepository worktypeRepository; + + @Test + void findWorktypeByName() { + Worktype found = worktypeRepository.findByName("Writer"); + assertNotNull(found); + Worktype notFound = worktypeRepository.findByName("er"); + assertNull(notFound); + } + + @Test + void findWorktypeByNameIgnoreCase() { + List worktypes = worktypeRepository.findByNameIgnoreCase("Writer".toLowerCase()); + assertNotNull(worktypes); + assertEquals(1, worktypes.size()); + assertEquals("Writer", worktypes.get(0).getName()); + } + + @Test + void searchWorktype() { + List worktypes = worktypeRepository.search("er"); + assertEquals(3, worktypes.size()); + assertTrue(worktypes.stream().map(worktype -> worktype.getName()).collect(Collectors.toList()).contains("Writer")); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeTest.java new file mode 100644 index 0000000..3145ee9 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/data/WorktypeTest.java @@ -0,0 +1,83 @@ +package de.thpeetz.kontor.comics.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.comics.TestConstants; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class WorktypeTest { + + @Autowired + private WorktypeRepository worktypeRepository; + + @Test + @Order(1) + void checkInitialDataLoad() { + List worktypes = worktypeRepository.findAll(); + assertEquals(3, worktypes.size()); + } + + @Test + @Order(2) + void findWorktypeByName() { + Worktype found = worktypeRepository.findByName(TestConstants.WORKTYPE_INKER); + assertNotNull(found); + assertEquals(TestConstants.WORKTYPE_INKER, found.getName()); + Worktype notFound = worktypeRepository.findByName("er"); + assertNull(notFound); + } + + @Test + @Order(3) + void throwExceptionWhenArtistSavedWithEmptyName() { + Worktype worktype1 = new Worktype(); + assertThrows(TransactionSystemException.class, () -> { + worktypeRepository.save(worktype1); + }); + } + + @Test + @Order(4) + void saveWorktypeWithIdenticalName() { + Worktype worktype1 = new Worktype(); + worktype1.setName(TestConstants.WORKTYPE_NAME); + worktypeRepository.save(worktype1); + Worktype worktype2 = new Worktype(); + worktype2.setName(TestConstants.WORKTYPE_NAME); + assertThrows(DataIntegrityViolationException.class, () -> { + worktypeRepository.save(worktype2); + }); + worktypeRepository.delete(worktype1); + } + + @Test + @Order(5) + void saveWorktype() { + Worktype worktype = new Worktype(); + worktype.setName(TestConstants.WORKTYPE_NAME); + worktypeRepository.save(worktype); + assertEquals(TestConstants.WORKTYPE_COUNT + 1, worktypeRepository.count()); + } + + @Test + @Order(6) + void deleteWorktype() { + Worktype worktype = worktypeRepository.findByName(TestConstants.WORKTYPE_NAME); + assertNotNull(worktype); + worktypeRepository.delete(worktype); + assertEquals(TestConstants.WORKTYPE_COUNT, worktypeRepository.count()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/comics/services/ComicServiceTest.java b/springboot/src/test/java/de/thpeetz/kontor/comics/services/ComicServiceTest.java new file mode 100644 index 0000000..d449298 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/comics/services/ComicServiceTest.java @@ -0,0 +1,350 @@ +package de.thpeetz.kontor.comics.services; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.comics.TestConstants; +import de.thpeetz.kontor.comics.data.Artist; +import de.thpeetz.kontor.comics.data.Comic; +import de.thpeetz.kontor.comics.data.ComicWork; +import de.thpeetz.kontor.comics.data.Issue; +import de.thpeetz.kontor.comics.data.Publisher; +import de.thpeetz.kontor.comics.data.StoryArc; +import de.thpeetz.kontor.comics.data.TradePaperback; +import de.thpeetz.kontor.comics.data.Volume; +import de.thpeetz.kontor.comics.data.Worktype; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class ComicServiceTest { + + private static final Logger log = LoggerFactory.getLogger(ComicServiceTest.class); + @Autowired + private ComicService comicService; + + @Test + @Order(1) + void testFindAllArtists() { + assertEquals(TestConstants.ARTIST_COUNT, comicService.findAllArtists(null).size()); + assertEquals(1, comicService.findAllArtists("Turn").size()); + } + + @Test + @Order(1) + void testFindArtistByName() { + assertNull(comicService.findArtistByName("Lee, Stan")); + Artist artist = comicService.findArtistByName("Turner, Michael"); + assertNotNull(artist); + assertNotNull(artist.getComicWorks()); + assertFalse(artist.getComicWorks().isEmpty()); + } + + @Test + @Order(2) + void testSaveArtist() { + Artist artist = new Artist(); + artist.setName(TestConstants.ARTIST_NAME); + comicService.saveArtist(artist); + assertEquals(TestConstants.ARTIST_COUNT + 1, comicService.findAllArtists(null).size()); + } + + @Test + @Order(3) + void testDeleteArtist() { + List artists = comicService.findAllArtists(TestConstants.ARTIST_NAME); + assertEquals(1, artists.size()); + comicService.deleteArtist(artists.get(0)); + assertEquals(TestConstants.ARTIST_COUNT, comicService.findAllArtists(null).size()); + } + + @Test + @Order(4) + void testFindAllPublishers() { + assertEquals(TestConstants.PUBLISHER_COUNT, comicService.findAllPublishers(null).size()); + assertEquals(1, comicService.findAllPublishers("Cow").size()); + } + + @Test + @Order(4) + void testFindPublisherByName() { + Publisher publisher = comicService.findPublisherByName("Marvel"); + assertNotNull(publisher); + assertNotNull(publisher.getComics()); + assertEquals(TestConstants.MARVEL_COMIC_COUNT, publisher.getComics().size()); + assertNull(comicService.findPublisherByName(TestConstants.PUBLISHER_NAME)); + } + + @Test + @Order(5) + void testSavePublisher() { + Publisher publisher = new Publisher(); + publisher.setName(TestConstants.PUBLISHER_NAME); + comicService.savePublisher(publisher); + assertEquals(TestConstants.PUBLISHER_COUNT + 1, comicService.findAllPublishers(null).size()); + } + + @Test + @Order(6) + void testDeletePublisher() { + List publishers = comicService.findAllPublishers(TestConstants.PUBLISHER_NAME); + assertEquals(1, publishers.size()); + comicService.deletePublisher(publishers.get(0)); + assertEquals(TestConstants.PUBLISHER_COUNT, comicService.findAllPublishers(null).size()); + } + + @Test + @Order(7) + void testFindAllWorktypes() { + assertEquals(TestConstants.WORKTYPE_COUNT, comicService.findAllWorktypes(null).size()); + assertEquals(1, comicService.findAllWorktypes("ite").size()); + } + + @Test + @Order(7) + void testFindWorktypeByName() { + Worktype worktype = comicService.findWorktypeByName("Writer"); + assertNotNull(worktype); + assertNotNull(worktype.getComicWorks()); + assertFalse(worktype.getComicWorks().isEmpty()); + worktype = comicService.findWorktypeByName("Inker"); + assertNotNull(worktype); + assertNotNull(worktype.getComicWorks()); + assertTrue(worktype.getComicWorks().isEmpty()); + assertNull(comicService.findWorktypeByName(TestConstants.WORKTYPE_NAME)); + } + + @Test + @Order(8) + void testSaveWorktype() { + Worktype worktype = new Worktype(); + worktype.setName(TestConstants.WORKTYPE_NAME); + comicService.saveWorktype(worktype); + assertEquals(TestConstants.WORKTYPE_COUNT + 1, comicService.findAllWorktypes(null).size()); + } + + @Test + @Order(9) + void testDeleteWorktype() { + List worktypes = comicService.findAllWorktypes(TestConstants.WORKTYPE_NAME); + assertEquals(1, worktypes.size()); + comicService.deleteWorktype(worktypes.get(0)); + assertEquals(TestConstants.WORKTYPE_COUNT, comicService.findAllWorktypes(null).size()); + } + + @Test + @Order(10) + void testFindComicByTitle() { + assertNull(comicService.findComicByTitle(null)); + assertNotNull(comicService.findComicByTitle("Danger Girl")); + assertNull(comicService.findComicByTitle("x-men")); + } + + @Test + @Order(11) + void testSaveComic() { + Publisher marvel = comicService.findAllPublishers("Marvel").get(0); + Comic comic = new Comic(); + comic.setTitle(TestConstants.COMIC_TITLE); + comic.setPublisher(marvel); + comicService.saveComic(comic); + assertEquals(TestConstants.COMIC_COUNT + 1, comicService.findAllComics(null).size()); + } + + @Test + @Order(12) + void testDeleteComic() { + assertEquals(TestConstants.COMIC_COUNT + 1, comicService.findAllComics(null).size()); + List comics = comicService.findAllComics(TestConstants.COMIC_TITLE); + assertEquals(1, comics.size()); + Comic comic = comicService.findComicByTitle(TestConstants.COMIC_TITLE); + assertNotNull(comic); + comicService.deleteComic(comic); + Publisher marvel = comicService.findPublisherByName("Marvel"); + assertNotNull(marvel.getComics()); + assertEquals(TestConstants.MARVEL_COMIC_COUNT, marvel.getComics().size()); + assertEquals(0, comicService.findAllComics(TestConstants.COMIC_TITLE).size()); + assertEquals(TestConstants.COMIC_COUNT, comicService.findAllComics(null).size()); + } + + @Test + @Order(13) + void testFindAllIssues() { + assertEquals(TestConstants.ISSUE_COUNT, comicService.findAllIssues().size()); + } + + @Test + @Order(14) + void testFindAllIssuesForComic() { + Comic comic = TestConstants.getComicWithIssues(comicService); + List issues = comicService.findAllIssuesForComic(comic); + assertEquals(12, issues.size()); + } + + @Test + @Order(15) + void testSaveIssue() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + Issue issue = new Issue(); + issue.setComic(comic); + issue.setIssueNumber(TestConstants.ISSUE_ISSUENUMBER); + comicService.saveIssue(issue); + assertEquals(TestConstants.ISSUE_COUNT + 1, comicService.findAllIssues().size()); + comic = TestConstants.getComicWithoutReferences(comicService); + assertNotNull(comic.getIssues()); + assertEquals(1, comic.getIssues().size()); + } + + @Test + @Order(16) + void testDeleteIssue() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + List issues = comicService.findAllIssuesForComic(comic); + assertEquals(1, issues.size()); + comicService.deleteIssue(issues.get(0)); + assertEquals(TestConstants.ISSUE_COUNT, comicService.findAllIssues().size()); + } + + @Test + @Order(17) + void testFindAllTradePaperbacks() { + assertEquals(TestConstants.TRADEPAPERBACK_COUNT, comicService.findAllTradePaperbacks(null).size()); + assertEquals(7, comicService.findAllTradePaperbacks("of").size()); + } + + @Test + @Order(18) + void testSaveTradePaperBack() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + TradePaperback tradePaperback = new TradePaperback(); + tradePaperback.setComic(comic); + tradePaperback.setName(TestConstants.TRADEPAPERBACK_NAME); + comicService.saveTradePaperBack(tradePaperback); + assertEquals(TestConstants.TRADEPAPERBACK_COUNT + 1, comicService.findAllTradePaperbacks(null).size()); + } + + @Test + @Order(19) + void testDeleteTradePaperBack() { + List tradePaperbacks = comicService.findAllTradePaperbacks(TestConstants.TRADEPAPERBACK_NAME); + assertEquals(1, tradePaperbacks.size()); + comicService.deleteTradePaperBack(tradePaperbacks.get(0)); + assertEquals(TestConstants.TRADEPAPERBACK_COUNT, comicService.findAllTradePaperbacks(null).size()); + } + + @Test + @Order(20) + void testFindAllStoryArcs() { + assertEquals(TestConstants.STORYARC_COUNT, comicService.findAllStoryArcs().size()); + } + + @Test + @Order(21) + void testFindAllStoryArcsForComic() { + assertEquals(TestConstants.STORYARC_COUNT, comicService.findAllStoryArcsForComic(null).size()); + Comic comic = TestConstants.getComicWithStoryArcs(comicService); + assertEquals(3, comicService.findAllStoryArcsForComic(comic).size()); + } + + @Test + @Order(22) + void testSaveStoryArc() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + StoryArc storyArc = new StoryArc(); + storyArc.setName(TestConstants.STORYARC_NAME); + storyArc.setComic(comic); + comicService.saveStoryArc(storyArc); + assertEquals(TestConstants.STORYARC_COUNT + 1, comicService.findAllStoryArcs().size()); + } + + @Test + @Order(23) + void testDeleteStoryArc() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + List storyArcs = comicService.findAllStoryArcsForComic(comic); + assertEquals(1, storyArcs.size()); + comicService.deleteStoryArc(storyArcs.get(0)); + assertEquals(TestConstants.STORYARC_COUNT, comicService.findAllStoryArcs().size()); + } + + @Test + @Order(24) + void testFindAllVolumes() { + assertEquals(TestConstants.VOLUME_COUNT, comicService.findAllVolumes().size()); + } + + @Test + @Order(25) + void testSaveVolume() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + Volume volume = new Volume(); + volume.setName(TestConstants.VOLUME_NAME); + volume.setComic(comic); + comicService.saveVolume(volume); + assertEquals(TestConstants.VOLUME_COUNT + 1, comicService.findAllVolumes().size()); + } + + @Test + @Order(26) + void testFindAllVolumesForComic() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + List volumes = comicService.findAllVolumesForComic(comic); + assertEquals(1, volumes.size()); + } + + @Test + @Order(27) + void testDeleteVolume() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + List volumes = comicService.findAllVolumesForComic(comic); + comicService.deleteVolume(volumes.get(0)); + assertEquals(TestConstants.VOLUME_COUNT, comicService.findAllVolumes().size()); + } + + @Test + @Order(28) + void testFindAllComicWorks() { + assertEquals(TestConstants.COMICWORK_COUNT, comicService.findAllComicWorks().size()); + } + + @Test + @Order(29) + void testSaveComicWork() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + Artist artist = TestConstants.getArtistWithoutReferences(comicService); + Worktype worktype = TestConstants.getWorktypeWithoutReferences(comicService); + ComicWork comicWork = new ComicWork(); + comicWork.setComic(comic); + comicWork.setArtist(artist); + comicWork.setWorkType(worktype); + comicService.saveComicWork(comicWork); + assertEquals(TestConstants.COMICWORK_COUNT + 1, comicService.findAllComicWorks().size()); + } + + @Test + @Order(30) + void testDeleteComicWork() { + Comic comic = TestConstants.getComicWithoutReferences(comicService); + Artist artist = TestConstants.getArtistWithoutReferences(comicService); + Worktype worktype = TestConstants.getWorktypeWithoutReferences(comicService); + List comicWorks = comic.getComicWorks(); + assertNotNull(comicWorks); + assertNotNull(artist.getComicWorks()); + assertNotNull(worktype.getComicWorks()); + assertEquals(1, comicWorks.size()); + assertEquals(1, artist.getComicWorks().size()); + assertEquals(1, worktype.getComicWorks().size()); + ComicWork comicWork = comicWorks.get(0); + comicService.deleteComicWork(comicWork); + assertEquals(TestConstants.COMICWORK_COUNT, comicService.findAllComicWorks().size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/media/TestConstants.java b/springboot/src/test/java/de/thpeetz/kontor/media/TestConstants.java new file mode 100644 index 0000000..32ee883 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/media/TestConstants.java @@ -0,0 +1,5 @@ +package de.thpeetz.kontor.media; + +public class TestConstants { + public static final String URL = "https://example.com/link.mp4"; +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaArticleTest.java b/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaArticleTest.java new file mode 100644 index 0000000..2f2adc7 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaArticleTest.java @@ -0,0 +1,32 @@ +package de.thpeetz.kontor.media.data; + +import de.thpeetz.kontor.media.services.MediaArticleService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@SpringBootTest +public class MediaArticleTest { + + @Autowired + private MediaArticleRepository mediaArticleRepository; + + @Test + void checkInitialLoad() { + assertTrue(mediaArticleRepository.findAll().isEmpty()); + } + + @Test + void checkDefaultValues() { + MediaArticle mediaArticle = new MediaArticle(); + assertNull(mediaArticle.getUrl()); + assertNull(mediaArticle.getTitle()); + assertNull(mediaArticle.getCreatedDate()); + assertNull(mediaArticle.getLastModifiedDate()); + assertNull(mediaArticle.getId()); + assertFalse(mediaArticle.isReview()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaFileTest.java b/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaFileTest.java new file mode 100644 index 0000000..6ca61ce --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaFileTest.java @@ -0,0 +1,37 @@ +package de.thpeetz.kontor.media.data; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +public class MediaFileTest { + + @Autowired + private MediaFileRepository mediaFileRepository; + + @Test + void checkInitialDataLoad() { + List mediaFileList = mediaFileRepository.findAll(); + assertTrue(mediaFileList.isEmpty()); + } + + @Test + void checkDefaultValues() { + MediaFile mediaFile = new MediaFile(); + assertNull(mediaFile.getUrl()); + assertNull(mediaFile.getTitle()); + assertNull(mediaFile.getFileName()); + assertNull(mediaFile.getPath()); + assertNull(mediaFile.getCloudLink()); + assertNull(mediaFile.getCreatedDate()); + assertNull(mediaFile.getLastModifiedDate()); + assertNull(mediaFile.getId()); + assertFalse(mediaFile.isReview()); + assertFalse(mediaFile.isShouldDownload()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaVideoTest.java b/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaVideoTest.java new file mode 100644 index 0000000..1a40335 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/media/data/MediaVideoTest.java @@ -0,0 +1,38 @@ +package de.thpeetz.kontor.media.data; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@SpringBootTest +public class MediaVideoTest { + + @Autowired + private MediaVideoRepository mediaVideoRepository; + + @Test + @Order(1) + void checkInitialLoad() { + assertTrue(mediaVideoRepository.findAll().isEmpty()); + } + + @Test + @Order(2) + void checkDefaultValues() { + MediaVideo mediaVideo = new MediaVideo(); + assertNull(mediaVideo.getUrl()); + assertNull(mediaVideo.getTitle()); + assertNull(mediaVideo.getFileName()); + assertNull(mediaVideo.getPath()); + assertNull(mediaVideo.getCloudLink()); + assertNull(mediaVideo.getCreatedDate()); + assertNull(mediaVideo.getLastModifiedDate()); + assertNull(mediaVideo.getId()); + assertFalse(mediaVideo.isReview()); + assertFalse(mediaVideo.isShouldDownload()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaArticleServiceTest.java b/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaArticleServiceTest.java new file mode 100644 index 0000000..6178aa8 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaArticleServiceTest.java @@ -0,0 +1,50 @@ +package de.thpeetz.kontor.media.services; + +import de.thpeetz.kontor.media.TestConstants; +import de.thpeetz.kontor.media.data.MediaArticle; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MediaArticleServiceTest { + + @Autowired + MediaArticleService mediaArticleService; + + @Test + @Order(1) + void findAllMediaArticles() { + assertTrue(mediaArticleService.findAllMediaArticles(null).isEmpty()); + } + + @Test + @Order(2) + void saveMediaArticle() { + int mediaArticleCount = mediaArticleService.findAllMediaArticles(null).size(); + MediaArticle mediaArticle = new MediaArticle(); + mediaArticle.setUrl(TestConstants.URL); + mediaArticleService.saveMediaArticle(mediaArticle); + assertEquals(++mediaArticleCount, mediaArticleService.findAllMediaArticles(null).size()); + } + + @Test + @Order(3) + void deleteMediaArticle() { + int mediaArticleCount = mediaArticleService.findAllMediaArticles(null).size(); + List mediaArticleList = mediaArticleService.findAllMediaArticles(TestConstants.URL); + assertEquals(1, mediaArticleService.findAllMediaArticles(null).size()); + mediaArticleService.deleteMediaArticle(mediaArticleList.get(0)); + assertEquals(--mediaArticleCount, mediaArticleService.findAllMediaArticles(null).size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaFileServiceTest.java b/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaFileServiceTest.java new file mode 100644 index 0000000..2201be5 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaFileServiceTest.java @@ -0,0 +1,48 @@ +package de.thpeetz.kontor.media.services; + +import de.thpeetz.kontor.media.TestConstants; +import de.thpeetz.kontor.media.data.MediaFile; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class MediaFileServiceTest { + + @Autowired + private MediaFileService mediaFileService; + + @Test + @Order(1) + void testFindAllMediaFiles() { + assertTrue(mediaFileService.findAllMediaFiles(null).isEmpty()); + } + + @Test + @Order(2) + void testSaveMediaFile() { + int mediaFileCount = mediaFileService.findAllMediaFiles(null).size(); + MediaFile mediaFile = new MediaFile(); + mediaFile.setUrl(TestConstants.URL); + mediaFileService.saveMediaFile(mediaFile); + assertEquals(mediaFileCount +1, mediaFileService.findAllMediaFiles(null).size()); + } + + @Test + @Order(3) + void testDeleteMediaFile() { + int mediaFileCount = mediaFileService.findAllMediaFiles(null).size(); + List mediaFileList = mediaFileService.findAllMediaFiles(TestConstants.URL); + assertEquals(1, mediaFileList.size()); + mediaFileService.deleteMediaFile(mediaFileList.get(0)); + assertEquals(--mediaFileCount, mediaFileService.findAllMediaFiles(null).size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaVideoServiceTest.java b/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaVideoServiceTest.java new file mode 100644 index 0000000..36a6fb1 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/media/services/MediaVideoServiceTest.java @@ -0,0 +1,48 @@ +package de.thpeetz.kontor.media.services; + +import de.thpeetz.kontor.media.TestConstants; +import de.thpeetz.kontor.media.data.MediaArticle; +import de.thpeetz.kontor.media.data.MediaVideo; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Slf4j +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class MediaVideoServiceTest { + + @Autowired + private MediaVideoService mediaVideoService; + + @Test + void findAllMediaVideos() { + assertTrue(mediaVideoService.findAllMediaVideos(null).isEmpty()); + } + + @Test + void saveMediaVideo() { + int mediaVideoCount = mediaVideoService.findAllMediaVideos(null).size(); + MediaVideo mediaVideo = new MediaVideo(); + mediaVideo.setUrl(TestConstants.URL); + mediaVideoService.saveMediaVideo(mediaVideo); + assertEquals(++mediaVideoCount, mediaVideoService.findAllMediaVideos(null).size()); + } + + @Test + void deleteMediaVideo() { + int mediaVideoCount = mediaVideoService.findAllMediaVideos(null).size(); + List mediaVideoList = mediaVideoService.findAllMediaVideos(TestConstants.URL); + assertEquals(1, mediaVideoList.size()); + mediaVideoService.deleteMediaVideo(mediaVideoList.get(0)); + assertEquals(--mediaVideoCount, mediaVideoService.findAllMediaVideos(null).size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/TestConstants.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/TestConstants.java new file mode 100644 index 0000000..a05c1dd --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/TestConstants.java @@ -0,0 +1,39 @@ +package de.thpeetz.kontor.tysc; + +public class TestConstants { + + public static final Integer CARD_COUNT = 10; + public static final Integer CARD_CARDNUMBER = 999; + public static final Integer CARD_YEAR = 2000; + public static final Integer CARDSET_COUNT = 15; + public static final Integer FOOTBALL_TEAM_COUNT = 33; + public static final Integer FOOTBALL_POSITION_COUNT = 25; + public static final Integer PLAYER_COUNT = 38; + public static final Integer PLAYER_CHRIS_COUNT = 3; + public static final Integer POSITION_COUNT = 44; + public static final Integer POSITION_CENTER_COUNT = 3; + public static final Integer ROOSTER_COUNT = 11; + public static final Integer ROOSTER_YEAR = 1900; + public static final Integer SPORT_COUNT = 4; + public static final Integer TEAM_COUNT = 122; + public static final Integer VENDOR_COUNT = 9; + public static final String CARDSET_NAME = "CardSet"; + public static final String CARDSET_MYSTIQUE_NAME = "Mystique"; + public static final String DOLPHINS_NAME = "Miami Dolphins"; + public static final Object DOLPHINS_SHORT = "Dolphins"; + public static final String FOOTBALL_NAME = "Football"; + public static final String PLAYER_FIRSTNAME = "FirstName"; + public static final String PLAYER_LASTNAME = "LastName"; + public static final String PLAYER_CHRIS_NAME = "Chris"; + public static final String POSITION_CENTER = "Center"; + public static final String POSITION_NAME = "Position"; + public static final String POSITION_SHORTNAME = "POS"; + public static final String QUARTERBACK_NAME = "Quarterback"; + public static final String QUARTERBACK_SHORTNAME = "QB"; + public static final String SPORT_NAME = "Sport"; + public static final String TEAM_NAME = "Musterstadt Team"; + public static final String TEAM_SHORTNAME = "Team"; + public static final String TOPPS_NAME = "Topps"; + public static final String VENDOR_FLEER = "Fleer"; + public static final String VENDOR_NAME = "Vendor"; +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardRepositoryTest.java new file mode 100644 index 0000000..e2ff8b2 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardRepositoryTest.java @@ -0,0 +1,35 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CardRepositoryTest { + + @Autowired + private CardRepository cardRepository; + + @Test + void testSearchByFields() { + List cards = cardRepository.findAll(); + Card existingCard = cards.get(3); + Vendor vendor = existingCard.getVendor(); + CardSet cardSet = existingCard.getCardSet(); + Rooster rooster = existingCard.getRooster(); + int cardNumber = existingCard.getCardNumber(); + int year = existingCard.getYear(); + Card card = cardRepository.search(vendor, cardSet, rooster, cardNumber, year); + assertNotNull(card); + } + + @Test + void testSearch() { + assertEquals(2, cardRepository.search("2").size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetRepositoryTest.java new file mode 100644 index 0000000..71a08ba --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetRepositoryTest.java @@ -0,0 +1,42 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class CardSetRepositoryTest { + + @Autowired + private CardSetRepository cardSetRepository; + + @Autowired + private VendorRepository vendorRepository; + + @Test + void testFindByName() { + List cardSets = cardSetRepository.findByName(TestConstants.CARDSET_MYSTIQUE_NAME); + assertEquals(1, cardSets.size()); + } + + @Test + void testFindByNameAndVendor() { + Vendor fleer = vendorRepository.findByName(TestConstants.VENDOR_FLEER); + assertNotNull(fleer); + CardSet cardSet = cardSetRepository.findByNameAndVendor(TestConstants.CARDSET_MYSTIQUE_NAME, fleer); + assertNotNull(cardSet); + } + + @Test + void testSearch() { + List cardSets = cardSetRepository.search("SP"); + assertEquals(3, cardSets.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetTest.java new file mode 100644 index 0000000..a5699d6 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardSetTest.java @@ -0,0 +1,22 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; +import de.thpeetz.kontor.tysc.services.CardService; + +@SpringBootTest +class CardSetTest { + + @Autowired + private CardService cardService; + + @Test + void checkInitialDataLoad() { + assertEquals(TestConstants.CARDSET_COUNT, cardService.findAllCardSets(null).size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardTest.java new file mode 100644 index 0000000..0d71d8d --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/CardTest.java @@ -0,0 +1,22 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; +import de.thpeetz.kontor.tysc.services.CardService; + +@SpringBootTest +class CardTest { + + @Autowired + private CardService cardService; + + @Test + void checkInitialDataLoad() { + assertEquals(TestConstants.CARD_COUNT, cardService.findAllCards(null).size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionRepositoryTest.java new file mode 100644 index 0000000..a9273db --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionRepositoryTest.java @@ -0,0 +1,59 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class FieldPositionRepositoryTest { + + @Autowired + private FieldPositionRepository fieldPositionRepository; + + @Autowired + private SportRepository sportRepository; + + @Test + void testFindByShortName() { + FieldPosition position = fieldPositionRepository.findByShortName(TestConstants.QUARTERBACK_SHORTNAME); + assertNotNull(position); + assertEquals(TestConstants.QUARTERBACK_NAME, position.getName()); + } + + @Test + void testFindByShortNameAndSport() { + Sport sport = sportRepository.findByName(TestConstants.FOOTBALL_NAME); + FieldPosition position = fieldPositionRepository.findByShortNameAndSport(TestConstants.QUARTERBACK_SHORTNAME, sport); + assertNotNull(position); + assertEquals(TestConstants.QUARTERBACK_NAME, position.getName()); + } + + @Test + void testFindByShortNameIgnoreCase() { + List positions = fieldPositionRepository + .findByShortNameIgnoreCase(TestConstants.QUARTERBACK_SHORTNAME.toLowerCase()); + assertNotNull(positions); + assertEquals(1, positions.size()); + assertEquals(TestConstants.QUARTERBACK_NAME, positions.get(0).getName()); + } + + @Test + void testFindBySport() { + Sport sport = sportRepository.findByName(TestConstants.FOOTBALL_NAME); + List positions = fieldPositionRepository.findBySport(sport); + assertEquals(TestConstants.FOOTBALL_POSITION_COUNT, positions.size()); + } + + @Test + void testSearch() { + List positions = fieldPositionRepository.search("back"); + assertEquals(8, positions.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionTest.java new file mode 100644 index 0000000..8a8b9b7 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/FieldPositionTest.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class FieldPositionTest { + + @Autowired + private FieldPositionRepository fieldPositionRepository; + + @Test + void checkInitialDataLoad() { + assertEquals(Integer.toUnsignedLong(TestConstants.POSITION_COUNT), fieldPositionRepository.count()); + } + + @Test + void throwExceptionWhenPositionSavedWithEmptyName() { + FieldPosition fieldPosition = new FieldPosition(); + assertThrows(TransactionSystemException.class, () -> { + fieldPositionRepository.save(fieldPosition); + }); + fieldPosition.setName("Position"); + assertThrows(TransactionSystemException.class, () -> { + fieldPositionRepository.save(fieldPosition); + }); + } + + @Test + void throwExceptionWhenPositionSavedWithExistingName() { + FieldPosition existingFieldPosition = fieldPositionRepository.findAll().get(11); + FieldPosition fieldPosition = new FieldPosition(); + fieldPosition.setName(existingFieldPosition.getName()); + assertThrows(TransactionSystemException.class, () -> { + fieldPositionRepository.save(fieldPosition); + }); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerRepositoryTest.java new file mode 100644 index 0000000..7cbd540 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerRepositoryTest.java @@ -0,0 +1,31 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class PlayerRepositoryTest { + + @Autowired + private PlayerRepository playerRepository; + + @Test + void testFindByFirstNameAndLastName() { + Player existingPlayer = playerRepository.findAll().get(11); + String firstName = existingPlayer.getFirstName(); + String lastName = existingPlayer.getLastName(); + Player found = playerRepository.findByFirstNameAndLastName(firstName, lastName); + assertEquals(existingPlayer.getFullName(), found.getFullName()); + } + + @Test + void testSearch() { + List players = playerRepository.search("can"); + assertEquals(1, players.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerTest.java new file mode 100644 index 0000000..941537b --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/PlayerTest.java @@ -0,0 +1,53 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class PlayerTest { + + @Autowired + private PlayerRepository playerRepository; + + @Test + @Order(1) + void checkInitialDataLoad() { + List players = playerRepository.findAll(); + assertEquals(TestConstants.PLAYER_COUNT, players.size()); + } + + @Test + @Order(2) + void throwExceptionWhenPlayerSavedWithEmptyName() { + Player player = new Player(); + assertThrows(TransactionSystemException.class, () -> { + playerRepository.save(player); + }); + } + + @Test + @Order(3) + void throwExceptionWhenPlayerSavedWithExistingName() { + Player existingPlayer = playerRepository.findAll().get(10); + assertNotNull(existingPlayer); + Player player = new Player(); + player.setFirstName(existingPlayer.getFirstName()); + player.setLastName(existingPlayer.getLastName()); + assertThrows(DataIntegrityViolationException.class, () -> { + playerRepository.save(player); + }); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterRepositoryTest.java new file mode 100644 index 0000000..fa56d90 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterRepositoryTest.java @@ -0,0 +1,29 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class RoosterRepositoryTest { + + @Autowired + private RoosterRepository roosterRepository; + + @Test + void testFindByReferences() { + List roosters = roosterRepository.findAll(); + Rooster existingRooster = roosters.get(4); + Team team = existingRooster.getTeam(); + Player player = existingRooster.getPlayer(); + FieldPosition position = existingRooster.getPosition(); + int year = existingRooster.getYear(); + Rooster rooster = roosterRepository.findByReferences(player, team, position, year); + assertNotNull(rooster); + assertEquals(existingRooster.getId(), rooster.getId()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterTest.java new file mode 100644 index 0000000..caffea4 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/RoosterTest.java @@ -0,0 +1,47 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class RoosterTest { + + @Autowired + private RoosterRepository roosterRepository; + + @Test + void checkInitialDataLoad() { + List roosters = roosterRepository.findAll(); + assertEquals(TestConstants.ROOSTER_COUNT, roosters.size()); + } + + @Test + void checkFetchingData() { + Rooster rooster = roosterRepository.findAll().get(4); + assertNotNull(rooster.getTeam()); + assertNotNull(rooster.getPlayer()); + assertNotNull(rooster.getPosition()); + } + + @Test + void exceptionThrownWhenSavingDuplicateRooster() { + List roosters = roosterRepository.findAll(); + Rooster existingRooster = roosters.get(5); + Rooster rooster = new Rooster(); + rooster.setPlayer(existingRooster.getPlayer()); + rooster.setTeam(existingRooster.getTeam()); + rooster.setPosition(existingRooster.getPosition()); + rooster.setYear(existingRooster.getYear()); + assertThrows(DataIntegrityViolationException.class, () -> { + roosterRepository.save(rooster); + }); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportRepositoryTest.java new file mode 100644 index 0000000..c0db4be --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportRepositoryTest.java @@ -0,0 +1,40 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class SportRepositoryTest { + + @Autowired + private SportRepository sportRepository; + + @Test + void testFindByName() { + Sport sport = sportRepository.findByName(TestConstants.FOOTBALL_NAME); + assertNotNull(sport); + assertEquals(TestConstants.FOOTBALL_TEAM_COUNT, sport.getTeams().size()); + assertEquals(TestConstants.FOOTBALL_POSITION_COUNT, sport.getPositions().size()); + } + + @Test + void testFindByNameIgnoreCase() { + List sports = sportRepository.findByNameIgnoreCase(TestConstants.FOOTBALL_NAME.toLowerCase()); + assertNotNull(sports); + assertEquals(TestConstants.FOOTBALL_TEAM_COUNT, sports.get(0).getTeams().size()); + } + + @Test + void testSearch() { + List sports = sportRepository.search("ball"); + assertEquals(3, sports.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportTest.java new file mode 100644 index 0000000..e47bb50 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/SportTest.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class SportTest { + + @Autowired + private SportRepository sportRepository; + + @Test + void checkInitialDataLoad() { + List sports = sportRepository.findAll(); + assertEquals(TestConstants.SPORT_COUNT, sports.size()); + } + + @Test + void exceptionThrownWhenSavingSportWithEmptyName() { + Sport sport = new Sport(); + assertThrows(TransactionSystemException.class, () -> { + sportRepository.save(sport); + }); + } + + @Test + void exceptionThrownWhenSavingSportWithExistingName() { + Sport existingSport = sportRepository.findAll().get(0); + Sport sport = new Sport(); + sport.setName(existingSport.getName()); + assertThrows(DataIntegrityViolationException.class, () -> { + sportRepository.save(sport); + }); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamRepositoryTest.java new file mode 100644 index 0000000..86f4200 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamRepositoryTest.java @@ -0,0 +1,37 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class TeamRepositoryTest { + + @Autowired + private TeamRepository teamRepository; + + @Test + void testFindByName() { + Team team = teamRepository.findByName(TestConstants.DOLPHINS_NAME); + assertEquals(TestConstants.DOLPHINS_SHORT, team.getShortName()); + } + + @Test + void testFindByNameIgnoreCase() { + List teams = teamRepository.findByNameIgnoreCase(TestConstants.DOLPHINS_NAME.toLowerCase()); + assertEquals(1, teams.size()); + assertEquals(TestConstants.DOLPHINS_SHORT, teams.get(0).getShortName()); + } + + @Test + void testSearch() { + List teams = teamRepository.search("dol"); + assertEquals(1, teams.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamTest.java new file mode 100644 index 0000000..7bd4ba2 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/TeamTest.java @@ -0,0 +1,53 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class TeamTest { + + @Autowired + private TeamRepository teamRepository; + + @Test + void checkInitialDataLoad() { + List teams = teamRepository.findAll(); + assertEquals(TestConstants.TEAM_COUNT, teams.size()); + } + + @Test + void throwExceptionWhenTeamSavedWithEmptyName() { + Team team = new Team(); + assertThrows(TransactionSystemException.class, () -> { + teamRepository.save(team); + }); + } + + @Test + void throwExceptionWhenTeamSavedWithoutSport() { + Team team = new Team(); + team.setName(TestConstants.TEAM_NAME); + assertThrows(TransactionSystemException.class, () -> { + teamRepository.save(team); + }); + } + + @Test + void throwExceptionWhenTeamSavedWithExistingName() { + Team existingTeam = teamRepository.findAll().get(11); + Team team = new Team(); + team.setName(existingTeam.getName()); + team.setSport(existingTeam.getSport()); + assertThrows(TransactionSystemException.class, () -> { + teamRepository.save(team); + }); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorRepositoryTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorRepositoryTest.java new file mode 100644 index 0000000..9db7ef7 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorRepositoryTest.java @@ -0,0 +1,41 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class VendorRepositoryTest { + + @Autowired + private VendorRepository vendorRepository; + + @Test + void testFindByName() { + Vendor vendor = vendorRepository.findByName(TestConstants.TOPPS_NAME); + assertNotNull(vendor); + assertEquals(TestConstants.TOPPS_NAME, vendor.getName()); + } + + @Test + void testFindByNameIgnoreCase() { + List vendors = vendorRepository.findByNameIgnoreCase(TestConstants.TOPPS_NAME.toLowerCase()); + assertNotNull(vendors); + assertEquals(1, vendors.size()); + assertEquals(TestConstants.TOPPS_NAME, vendors.get(0).getName()); + } + + @Test + void testSearch() { + List vendors = vendorRepository.search("pp"); + assertNotNull(vendors); + assertEquals(2, vendors.size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorTest.java new file mode 100644 index 0000000..a16bd77 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/data/VendorTest.java @@ -0,0 +1,44 @@ +package de.thpeetz.kontor.tysc.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; + +import de.thpeetz.kontor.tysc.TestConstants; + +@SpringBootTest +class VendorTest { + + @Autowired + private VendorRepository vendorRepository; + + @Test + void checkInitialDataLoad() { + List vendors = vendorRepository.findAll(); + assertEquals(TestConstants.VENDOR_COUNT, vendors.size()); + } + + @Test + void exceptionThrownWhenSavingVendortWithEmptyName() { + Vendor vendor = new Vendor(); + assertThrows(TransactionSystemException.class, () -> { + vendorRepository.save(vendor); + }); + } + + @Test + void exceptionThrownWhenSavingSportWithExistingName() { + Vendor vendor = new Vendor(); + vendor.setName(TestConstants.TOPPS_NAME); + assertThrows(DataIntegrityViolationException.class, () -> { + vendorRepository.save(vendor); + }); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/services/CardServiceTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/services/CardServiceTest.java new file mode 100644 index 0000000..e215217 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/services/CardServiceTest.java @@ -0,0 +1,126 @@ +package de.thpeetz.kontor.tysc.services; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; +import de.thpeetz.kontor.tysc.data.Card; +import de.thpeetz.kontor.tysc.data.CardSet; +import de.thpeetz.kontor.tysc.data.Rooster; +import de.thpeetz.kontor.tysc.data.Vendor; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class CardServiceTest { + + @Autowired + private CardService cardService; + + @Autowired + private SportService sportService; + + @Test + @Order(1) + void testFindAllVendors() { + assertEquals(TestConstants.VENDOR_COUNT, cardService.findAllVendors(null).size()); + assertEquals(2, cardService.findAllVendors("pp").size()); + } + + @Test + @Order(2) + void testSaveVendor() { + Vendor vendor = new Vendor(); + vendor.setName(TestConstants.VENDOR_NAME); + vendor = cardService.saveVendor(vendor); + assertNotNull(vendor); + assertEquals(TestConstants.VENDOR_COUNT + 1, cardService.findAllVendors(null).size()); + } + + @Test + @Order(3) + void testDeleteVendor() { + List vendors = cardService.findAllVendors(TestConstants.VENDOR_NAME); + assertEquals(1, vendors.size()); + Vendor vendor = vendors.get(0); + cardService.deleteVendor(vendor); + assertEquals(TestConstants.VENDOR_COUNT, cardService.findAllVendors(null).size()); + } + + @Test + @Order(4) + void testFindAllCardSets() { + assertEquals(TestConstants.CARDSET_COUNT, cardService.findAllCardSets(null).size()); + assertEquals(1, cardService.findAllCardSets("Ultra").size()); + } + + @Test + @Order(5) + void testSaveCardSet() { + List vendors = cardService.findAllVendors(TestConstants.VENDOR_FLEER); + assertEquals(1, vendors.size()); + Vendor vendor = vendors.get(0); + CardSet cardSet = new CardSet(); + cardSet.setName(TestConstants.CARDSET_NAME); + cardSet.setVendor(vendor); + cardSet.setInsertSet(false); + cardSet.setParallelSet(false); + cardService.saveCardSet(cardSet); + assertEquals(TestConstants.CARDSET_COUNT + 1, cardService.findAllCardSets(null).size()); + } + + @Test + @Order(6) + void testDeleteCardSet() { + List cardSets = cardService.findAllCardSets(TestConstants.CARDSET_NAME); + assertEquals(1, cardSets.size()); + CardSet cardSet = cardSets.get(0); + cardService.deleteCardSet(cardSet); + assertEquals(TestConstants.CARDSET_COUNT, cardService.findAllCardSets(null).size()); + } + + @Test + @Order(7) + void testFindAllCards() { + assertEquals(TestConstants.CARD_COUNT, cardService.findAllCards(null).size()); + assertEquals(1, cardService.findAllCards("112").size()); + } + + @Test + @Order(8) + void testSaveCard() { + List vendors = cardService.findAllVendors(TestConstants.VENDOR_FLEER); + assertEquals(1, vendors.size()); + Vendor vendor = vendors.get(0); + List cardSets = cardService.findAllCardSets(TestConstants.CARDSET_MYSTIQUE_NAME); + assertEquals(3, cardSets.size()); + CardSet cardSet = cardSets.get(0); + List roosters = sportService.findAllRoosters(); + Rooster rooster = roosters.get(0); + Card card = new Card(); + card.setCardNumber(TestConstants.CARD_CARDNUMBER); + card.setCardSet(cardSet); + card.setVendor(vendor); + card.setRooster(rooster); + card.setYear(TestConstants.CARD_YEAR); + cardService.saveCard(card); + assertEquals(TestConstants.CARD_COUNT + 1, cardService.findAllCards(null).size()); + } + + @Test + @Order(9) + void testDeleteCard() { + List cards = cardService.findAllCards(TestConstants.CARD_CARDNUMBER.toString()); + assertEquals(1, cards.size()); + Card card = cards.get(0); + cardService.deleteCard(card); + assertEquals(TestConstants.CARD_COUNT, cardService.findAllCards(null).size()); + } +} diff --git a/springboot/src/test/java/de/thpeetz/kontor/tysc/services/SportServiceTest.java b/springboot/src/test/java/de/thpeetz/kontor/tysc/services/SportServiceTest.java new file mode 100644 index 0000000..30b7ed6 --- /dev/null +++ b/springboot/src/test/java/de/thpeetz/kontor/tysc/services/SportServiceTest.java @@ -0,0 +1,200 @@ +package de.thpeetz.kontor.tysc.services; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import de.thpeetz.kontor.tysc.data.FieldPosition; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import de.thpeetz.kontor.tysc.TestConstants; +import de.thpeetz.kontor.tysc.data.Player; +import de.thpeetz.kontor.tysc.data.Rooster; +import de.thpeetz.kontor.tysc.data.Sport; +import de.thpeetz.kontor.tysc.data.Team; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +class SportServiceTest { + + @Autowired + private SportService sportService; + + + @Test + @Order(1) + void testFindAllSports() { + List sports = sportService.findAllSports(null); + assertEquals(TestConstants.SPORT_COUNT, sports.size()); + assertEquals(3, sportService.findAllSports("ball").size()); + } + + @Test + @Order(2) + void testSaveSport() { + Sport sport = new Sport(); + sport.setName(TestConstants.SPORT_NAME); + sport = sportService.saveSport(sport); + assertNotNull(sport); + assertEquals(TestConstants.SPORT_COUNT+1, sportService.findAllSports(null).size()); + } + + @Test + @Order(3) + void testDeleteSport() { + List sports = sportService.findAllSports(TestConstants.SPORT_NAME); + assertEquals(1, sports.size()); + Sport sport = sports.get(0); + sportService.deleteSport(sport); + assertEquals(TestConstants.SPORT_COUNT, sportService.findAllSports(null).size()); + } + + @Test + @Order(4) + void testFindAllPlayers() { + assertEquals(TestConstants.PLAYER_COUNT, sportService.findAllPlayers(null).size()); + assertEquals(TestConstants.PLAYER_CHRIS_COUNT, sportService.findAllPlayers(TestConstants.PLAYER_CHRIS_NAME).size()); + } + + @Test + @Order(5) + void testSavePlayer() { + Player player = new Player(); + player.setFirstName(TestConstants.PLAYER_FIRSTNAME); + player.setLastName(TestConstants.PLAYER_LASTNAME); + player = sportService.savePlayer(player); + assertNotNull(player); + } + + @Test + @Order(6) + void testDeletePlayer() { + List players = sportService.findAllPlayers(TestConstants.PLAYER_LASTNAME); + assertEquals(1, players.size()); + Player player = players.get(0); + sportService.deletePlayer(player); + assertEquals(TestConstants.PLAYER_COUNT, sportService.findAllPlayers(null).size()); + } + + + @Test + @Order(7) + void testFindAllPositions() { + assertEquals(TestConstants.POSITION_COUNT, sportService.findAllPositions(null).size()); + assertEquals(TestConstants.POSITION_CENTER_COUNT, sportService.findAllPositions(TestConstants.POSITION_CENTER).size()); + } + + @Test + @Order(8) + void testFindAllPositionsForSport() { + List sports = sportService.findAllSports(TestConstants.FOOTBALL_NAME); + assertEquals(1, sports.size()); + Sport football = sports.get(0); + assertEquals(TestConstants.FOOTBALL_POSITION_COUNT, sportService.findAllPositionsForSport(football).size()); + } + + @Test + @Order(9) + void testSavePosition() { + List sports = sportService.findAllSports(TestConstants.FOOTBALL_NAME); + Sport football = sports.get(0); + FieldPosition position = new FieldPosition(); + position.setSport(football); + position.setName(TestConstants.POSITION_NAME); + position.setShortName(TestConstants.POSITION_SHORTNAME); + sportService.savePosition(position); + assertEquals(TestConstants.POSITION_COUNT+1, sportService.findAllPositions(null).size()); + } + + @Test + @Order(10) + void testDeletePosition() { + List positions = sportService.findAllPositions(TestConstants.POSITION_NAME); + assertEquals(1, positions.size()); + FieldPosition position = positions.get(0); + sportService.deletePosition(position); + assertEquals(TestConstants.POSITION_COUNT, sportService.findAllPositions(null).size()); + } + + @Test + @Order(11) + void testFindAllTeams() { + assertEquals(TestConstants.TEAM_COUNT, sportService.findAllTeams(null).size()); + assertEquals(1, sportService.findAllTeams("sharks").size()); + } + + @Test + @Order(12) + void testSaveTeam() { + List sports = sportService.findAllSports(TestConstants.FOOTBALL_NAME); + Sport football = sports.get(0); + Team team = new Team(); + team.setSport(football); + team.setName(TestConstants.TEAM_NAME); + team.setShortName(TestConstants.TEAM_SHORTNAME); + sportService.saveTeam(team); + assertEquals(TestConstants.TEAM_COUNT+1, sportService.findAllTeams(null).size()); + } + + @Test + @Order(13) + void testDeleteTeam() { + List teams = sportService.findAllTeams(TestConstants.TEAM_NAME); + assertEquals(1, teams.size()); + Team team = teams.get(0); + sportService.deleteTeam(team); + assertEquals(TestConstants.TEAM_COUNT, sportService.findAllTeams(null).size()); + } + + @Test + @Order(14) + void testFindAllRoosters() { + assertEquals(TestConstants.ROOSTER_COUNT, sportService.findAllRoosters().size()); + } + + @Test + @Order(15) + void testFindRoosterByFields() { + List roosters = sportService.findAllRoosters(); + Rooster existingRooster = roosters.get(4); + Team team = existingRooster.getTeam(); + Player player = existingRooster.getPlayer(); + FieldPosition position = existingRooster.getPosition(); + int year = existingRooster.getYear(); + Rooster rooster = sportService.findRoosterByFields(team, player, position, year); + assertNotNull(rooster); + assertEquals(existingRooster.getId(), rooster.getId()); + } + + @Test + @Order(16) + void testSaveRooster() { + Rooster rooster = new Rooster(); + Player player = sportService.findAllPlayers(TestConstants.PLAYER_CHRIS_NAME).get(0); + rooster.setPlayer(player); + Team team = sportService.findAllTeams(TestConstants.DOLPHINS_NAME).get(0); + rooster.setTeam(team); + FieldPosition position = sportService.findAllPositions(TestConstants.QUARTERBACK_NAME).get(0); + rooster.setPosition(position); + rooster.setYear(TestConstants.ROOSTER_YEAR); + sportService.saveRooster(rooster); + assertEquals(TestConstants.ROOSTER_COUNT+1, sportService.findAllRoosters().size()); + } + + @Test + @Order(17) + void testDeleteRooster() { + Team team = sportService.findAllTeams(TestConstants.DOLPHINS_NAME).get(0); + Player player = sportService.findAllPlayers(TestConstants.PLAYER_CHRIS_NAME).get(0); + FieldPosition position = sportService.findAllPositions(TestConstants.QUARTERBACK_NAME).get(0); + Rooster rooster = sportService.findRoosterByFields(team, player, position, TestConstants.ROOSTER_YEAR); + assertNotNull(rooster); + sportService.deleteRooster(rooster); + assertEquals(TestConstants.ROOSTER_COUNT, sportService.findAllRoosters().size()); + } +} diff --git a/springboot/src/test/resources/application.properties b/springboot/src/test/resources/application.properties new file mode 100644 index 0000000..53be2f4 --- /dev/null +++ b/springboot/src/test/resources/application.properties @@ -0,0 +1,30 @@ +server.port=8085 + +spring.hibernate.dialect=org.hibernate.dialect.HSQLDialect +spring.jpa.database-platform=org.hibernate.dialect.HSQLDialect +spring.datasource.driverClassName=org.hsqldb.jdbc.JDBCDriver +spring.datasource.url=jdbc:hsqldb:mem:testDb +spring.datasource.username=sa +spring.datasource.password=sa + +#spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect +#spring.datasource.driverClassName=org.sqlite.JDBC +#spring.datasource.url=jdbc:sqlite:file:./kontorTesrDb?cache=shared +#spring.datasource.username=sa +#spring.datasource.password=sa + +spring.jpa.defer-datasource-initialization = true +#spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.sql.init.mode=always + +spring.mustache.check-template-location = false + +logging.level.org.atmosphere=INFO +logging.level.org.springframework.web=INFO +logging.level.guru.springframework.controllers=DEBUG +logging.level.org.hibernate=INFO +logging.level.de.thpeetz=DEBUG + +jwt.auth.secret=J6GOtcwC2NJI1l0VkHu20PacPFGTxpirBxWwynoHjsc= From 1a7da0ab9f2ed890b719ec0028d8a375242f19f5 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Tue, 7 Jan 2025 01:48:03 +0100 Subject: [PATCH 08/26] add column header info --- .gitignore | 1 + .gitlab-ci.yml | 36 ++ gui/model_config.py | 72 ++-- gui/table_model.py | 11 +- .../kontor/admin/SetupModuleAdmin.java | 358 +++++++++--------- .../kontor/admin/data/MetaDataColumn.java | 6 + .../admin/services/MetaDataService.java | 20 +- .../kontor/admin/views/MetaDataForm.java | 8 +- .../kontor/admin/views/MetaDataView.java | 2 +- 9 files changed, 289 insertions(+), 225 deletions(-) create mode 100644 .gitlab-ci.yml diff --git a/.gitignore b/.gitignore index 575904a..2330d74 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ bonus/ icons/ icons-shadowless/ +.vscode/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..7231f34 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,36 @@ +image: gradle:8.6-jdk21-alpine + +stages: + - build + - test + - publish + +# Disable the Gradle daemon for Continuous Integration servers as correctness +# is usually a priority over speed in CI environments. Using a fresh +# runtime for each build is more reliable since the runtime is completely +# isolated from any previous builds. +variables: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + +before_script: + - GRADLE_USER_HOME="$(pwd)/.gradle" + - export GRADLE_USER_HOME + +build: + stage: build + script: + - cd springboot + - gradle assemble --no-daemon + +test: + stage: test + script: + - cd springboot + - gradle check --no-daemon + +publish: + stage: publish + script: + - cd springboot + - gradle --no-daemon publish -PgitlabPackageRegistryUsername=gitlab-ci-token -PgitlabPackageRegistryPassword="$CI_JOB_TOKEN" + diff --git a/gui/model_config.py b/gui/model_config.py index 1bea8a5..a9c6702 100644 --- a/gui/model_config.py +++ b/gui/model_config.py @@ -5,10 +5,11 @@ from PySide6.QtWidgets import QHBoxLayout, QCheckBox class KontorModelConfig: def __init__(self, db_config, main_window, table_name: str): - self.header = [] + self.header = {} self.filter = {} self.main_window = main_window self._table = table_name + self._table_id = None self.db_conn = mariadb.connect( host=db_config['mariadb']['host'], port=db_config['mariadb']['port'], @@ -19,64 +20,65 @@ class KontorModelConfig: self.get_table_config() def get_table_id(self): + if self._table_id is not None: + return cursor = self.db_conn.cursor() cursor.execute("SELECT id, created_date, last_modified_date FROM meta_data_table WHERE table_name=?", (self._table, )) rows = cursor.fetchall() if len(rows) == 1: - return rows[0][0] - return None + self._table_id = rows[0][0] def get_table_config(self): - table_id = self.get_table_id() + if self._table_id is None: + self.get_table_id() cursor = self.db_conn.cursor() - cursor.execute("SELECT id, column_name, column_order FROM meta_data_column WHERE table_id=? AND is_shown is true", (table_id, )) + cursor.execute("SELECT column_name, column_order, column_label FROM meta_data_column WHERE table_id=? AND is_shown is true ORDER bY column_order", (self._table_id, )) rows = cursor.fetchall() self.header.clear() - for (column_id, column_name, column_order) in rows: - self.header.insert(column_order-1, column_name) - print(f"retrieved {len(rows)} columns, set {len(self.header)} headers") - - def get_header(self) -> list: - self.get_table_config() - return self.header + order = 0 + for (column_name, column_order, column_label) in rows: + self.header[order] = { 'column': column_name, 'label': column_label, 'order': column_order} + order += 1 + # print(f"retrieved {len(rows)} columns, set {len(self.header)} headers") + cursor.execute("SELECT column_name, filter_label from meta_data_column WHERE table_id=? AND show_filter is true", (self._table_id, )) + rows = cursor.fetchall() + for row in rows: + self.filter[row[0]] = {'label': row[1], 'widget': None} + # print(f"retrieved {len(rows)} filters: {self.filter}") def get_filter(self) -> str: filter_rule = "" # print(self.filter["download"].isChecked()) - if self.filter["download"].isChecked(): - # print(self.filter["download"].isChecked()) - filter_rule = "WHERE should_download is true" - if self.filter["review"].isChecked(): - if len(filter_rule) > 0: - filter_rule += " AND " - else: - filter_rule += "WHERE " - filter_rule += "review is true" - print(f"{filter_rule=}") + for column, filter_info in self.filter.items(): + # print(column, filter_info) + if filter_info['widget'].isChecked(): + # print(column, filter_info, filter_rule, len(filter_rule)) + if len(filter_rule) < 1: + filter_rule += "WHERE " + if len(filter_rule) > 8: + filter_rule += " AND " + filter_rule += f"{column} is true" + # print(f"{filter_rule=}") return filter_rule def get_statement(self) -> str: filter_rule = self.get_filter() - self.get_table_config() + # self.get_table_config() columns = "" - for index in range(len(self.header)): + for index, column in self.header.items(): if index > 0: columns += ", " - columns += self.header[index] + columns += column['column'] statement = f"SELECT {columns} FROM media_file {filter_rule}" return statement def get_filter_layout(self) -> QHBoxLayout: filter_layout = QHBoxLayout() - download_checkbox = QCheckBox() - download_checkbox.setText("Download") - download_checkbox.checkStateChanged.connect(self.main_window.refresh) - self.filter["download"] = download_checkbox - review_checkbox = QCheckBox() - review_checkbox.setText("Review") - review_checkbox.checkStateChanged.connect(self.main_window.refresh) - self.filter["review"] = review_checkbox - filter_layout.addWidget(review_checkbox) - filter_layout.addWidget(download_checkbox) + 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/gui/table_model.py b/gui/table_model.py index 5fc5dc9..81eec89 100644 --- a/gui/table_model.py +++ b/gui/table_model.py @@ -19,7 +19,7 @@ class KontorTableModel(QAbstractTableModel): cursor = self._config.db_conn.cursor() cursor.execute(self._config.get_statement()) rows = cursor.fetchall() - print(len(rows)) + # print(len(rows)) if len(rows) > 0: self.beginResetModel() for row in rows: @@ -39,7 +39,7 @@ class KontorTableModel(QAbstractTableModel): def headerData(self, col, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return self._config.header[col] + return self._config.header[col]['column'] if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole: return str(col+1) @@ -48,6 +48,7 @@ class KontorTableModel(QAbstractTableModel): return None value = self._data[index.row()][index.column()] if role == Qt.ItemDataRole.DisplayRole: + # print('{}: {}'.format(value, type(value))) if isinstance(value, datetime): return value.strftime("%Y-%m-%d %M:%M:%S") if isinstance(value, str): @@ -57,7 +58,7 @@ class KontorTableModel(QAbstractTableModel): return self._main_window.tick else: return self._main_window.cross - return value + return str(value) if role == Qt.ItemDataRole.DecorationRole: if isinstance(value, bytes): # print('{}: {}'.format(value, type(value))) @@ -69,8 +70,8 @@ class KontorTableModel(QAbstractTableModel): 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.get_header()) + # print(f"Header count: {len(self._config.get_header())}") + return len(self._config.header) def setData(self, index, value, role=Qt.ItemDataRole.EditRole): if role == Qt.ItemDataRole.EditRole: diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java index 419c519..de81e3b 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java @@ -128,209 +128,209 @@ public class SetupModuleAdmin implements ApplicationListener column.getColumnName().equals(columnName))) { log.info("Column {} with name {} of table {} found, check Values", columnOrder, columnName, table.getTableName()); MetaDataColumn column = table.getTableColumns().get(columnOrder.intValue()-1); @@ -54,9 +54,21 @@ public class MetaDataService { log.debug("columnModifier has to be changed to {}", columnModifier); column.setColumnModifier(columnModifier); } - if (column.getIsShown() == null) { - log.debug("isShown set to false"); - column.setIsShown(Boolean.FALSE); + if (!column.getIsShown().equals(isShown)) { + log.debug("isShown has to be change to {}}", isShown); + column.setIsShown(isShown); + } + if (columnLabel != null && !columnLabel.equals(column.getColumnLabel())) { + log.debug("columnLabel has to be change to {}}", columnLabel); + column.setColumnLabel(columnLabel); + } + if (showFilter != null &&!showFilter.equals(column.getShowFilter())) { + log.debug("showFilter has to be change to {}}", showFilter); + column.setShowFilter(showFilter); + } + if (filterLabel != null && !filterLabel.equals(column.getFilterLabel())) { + log.debug("filterLabel has to be change to {}}", filterLabel); + column.setFilterLabel(filterLabel); } metaDataColumnRepository.save(column); } else { diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java index f70c161..19d4459 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java @@ -26,6 +26,7 @@ public class MetaDataForm extends FormLayout { TextField columnModifier = new TextField("Column Modifier"); IntegerField columnOrder = new IntegerField("Column Order"); Checkbox isShown = new Checkbox("Is Shown"); + Checkbox showFilter = new Checkbox("Show Filter"); Button save = new com.vaadin.flow.component.button.Button("Save"); Button delete = new com.vaadin.flow.component.button.Button("Delete"); @@ -39,7 +40,12 @@ public class MetaDataForm extends FormLayout { table.setItems(tables); table.setItemLabelGenerator(MetaDataTable::getTableName); - add(table, columnName, columnSyncName, columnModifier, columnOrder, isShown, createButtonsLayout()); + add(table, 2); + add(columnName, 2); + add(columnSyncName, 2); + add(columnModifier, 2); + add(columnOrder, 2); + add(isShown, showFilter, createButtonsLayout()); } private HorizontalLayout createButtonsLayout() { diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java index 661d4a3..f3b2509 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java @@ -42,7 +42,7 @@ public class MetaDataView extends VerticalLayout { private void configureGrid() { grid.addClassName("metadata-grid"); grid.setSizeFull(); - grid.setColumns("table.tableName", "columnName", "columnSyncName", "columnModifier", "columnOrder", "isShown"); + grid.setColumns("table.tableName", "columnName", "columnSyncName", "columnModifier", "columnOrder", "isShown", "showFilter"); grid.getColumns().forEach(col -> col.setAutoWidth(true)); grid.asSingleSelect().addValueChangeListener(event -> editMetaData(event.getValue())); } From c7691153310d061a08e66e89c3206d4006a8e393 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Tue, 7 Jan 2025 21:18:14 +0100 Subject: [PATCH 09/26] implement general data tab view --- gui/data.py | 79 +++++++++++++++++ gui/dialogs.py | 85 +++++++++++++++++++ gui/kontor.py | 54 +++++++----- gui/model_config.py | 48 +++-------- gui/table_model.py | 24 +++--- .../kontor/admin/SetupModuleAdmin.java | 30 +++---- 6 files changed, 236 insertions(+), 84 deletions(-) create mode 100644 gui/data.py create mode 100644 gui/dialogs.py diff --git a/gui/data.py b/gui/data.py new file mode 100644 index 0000000..bb4436c --- /dev/null +++ b/gui/data.py @@ -0,0 +1,79 @@ +import mariadb + + +class KontorDB: + + def __init__(self, db_config): + self.db_conn = mariadb.connect( + host=db_config['mariadb']['host'], + port=db_config['mariadb']['port'], + user=db_config['mariadb']['user'], + password=db_config['mariadb']['password'], + database=db_config['mariadb']['database'] + ) + + def get_table_id(self, table_name): + cursor = self.db_conn.cursor() + cursor.execute("SELECT id, created_date, last_modified_date FROM meta_data_table WHERE table_name=?", (table_name, )) + row = cursor.fetchone() + cursor.close() + return row[0] + + def get_table_names(self) -> list: + tables_names = [] + cursor = self.db_conn.cursor() + cursor.execute("SELECT id, table_name from meta_data_table") + rows = cursor.fetchall() + for (_, table_name) in rows: + tables_names.append(table_name) + cursor.close() + return tables_names + + def get_column_meta_data(self, table_id): + cursor = self.db_conn.cursor() + meta_data = {} + cursor.execute("SELECT column_name, column_order, column_label FROM meta_data_column WHERE table_id=? AND is_shown is true ORDER bY column_order", (table_id, )) + rows = cursor.fetchall() + order = 0 + for (column_name, column_order, column_label) in rows: + meta_data[order] = { 'column': column_name, 'label': column_label, 'order': column_order} + order += 1 + cursor.close() + # print(f"retrieved {len(rows)} columns, set {len(meta_data)} headers") + 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_data(self, table_name: str, columns: dict, where_clause: str) -> list: + data = [] + cursor = self.db_conn.cursor() + cursor.execute(self.get_statement(table_name, columns, where_clause)) + rows = cursor.fetchall() + print(len(rows)) + for row in rows: + # print(f"KontorDB.get_data: {row}") + data.append(list(row)) + cursor.close() + print(f"KontorDB.getData: return {len(data)}") + return data + + def get_statement(self, table: str, header: dict, where_clause): + columns = "" + for index, column in header.items(): + if index > 0: + columns += ", " + columns += column['column'] + if len(columns) == 0: + columns = "*" + statement = f"SELECT {columns} FROM {table} {where_clause}" + print(f"{statement=}") + return statement diff --git a/gui/dialogs.py b/gui/dialogs.py new file mode 100644 index 0000000..ae69c3d --- /dev/null +++ b/gui/dialogs.py @@ -0,0 +1,85 @@ +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QFileDialog, \ + QGroupBox, QCheckBox + + +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 = None + self.tables = [] + self._table_options = {} + + buttons = (QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + + self.buttonBox = QDialogButtonBox(buttons) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.label = QLabel() + self.label.setText("Export DB to 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) + + for table_name in self.kontor_db.get_table_names(): + check_box = QCheckBox(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 select_file(self): + file_dialog = QFileDialog() + file_dialog.setFileMode(QFileDialog.FileMode.AnyFile) + if file_dialog.exec(): + self.file_name = file_dialog.selectedFiles()[0] + 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/gui/kontor.py b/gui/kontor.py index 3b601ad..771c466 100644 --- a/gui/kontor.py +++ b/gui/kontor.py @@ -11,6 +11,8 @@ from PySide6.QtWidgets import QApplication, QLabel, QMainWindow from platformdirs import PlatformDirs from comic_model import ComicTableModel +from dialogs import ExportKontorDialog, ImportKontorDialog +from data import KontorDB from model_config import KontorModelConfig from table_model import KontorTableModel @@ -35,12 +37,13 @@ class MainWindow(QMainWindow): self.data = [] self.filter = {} + self.kontor_db = KontorDB(config) self.central_widget = QWidget() parent_layout = QVBoxLayout() self.central_widget.setLayout(parent_layout) self.tabs = QTabWidget() - self.tabs.addTab(self.generate_tab_comics(config), "Comics") - self.tabs.addTab(self.generate_tab_media_file(config), "MediaFile") + self.tabs.addTab(self.generate_data_tab("comic"), "Comics") + self.tabs.addTab(self.generate_data_tab("media_file"), "MediaFile") self.tabs.currentChanged.connect(self._tab_changed) #label.setAlignment(Qt.AlignmentFlag.AlignCenter) parent_layout.addWidget(self.tabs) @@ -52,9 +55,13 @@ class MainWindow(QMainWindow): 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) @@ -70,6 +77,10 @@ class MainWindow(QMainWindow): menu_bar.addMenu(kontor_menu) kontor_menu.addAction(self.importAction) kontor_menu.addAction(self.exportAction) + media_file_menu = QMenu("&MediaFile") + media_file_menu.addAction(self.updateTitleAction) + media_file_menu.addAction(self.downloadAction) + kontor_menu.addMenu(media_file_menu) # Help menu help_menu = QMenu("&Hilfe") menu_bar.addMenu(help_menu) @@ -91,36 +102,41 @@ class MainWindow(QMainWindow): 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(): + print(export_dlg.get_tables_to_export()) + self.statusBar.showMessage(f"export DB to {export_dlg.file_name}", 3000) + 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_tab_comics(self, db_configuration): - comic_tab = QWidget() - layout = QVBoxLayout() - comic_tab.setLayout(layout) - model = ComicTableModel(db_configuration, self) - self.data.append(model) - table_view = QTableView() - table_view.setModel(model) - layout.addWidget(table_view) - return comic_tab - - def generate_tab_media_file(self, db_configuration): - media_file_tab = QWidget() - table_config = KontorModelConfig(db_configuration, self, "media_file") + def generate_data_tab(self, table_name): + data_tab = QWidget() + table_config = KontorModelConfig(self.kontor_db, self, table_name) model = KontorTableModel(table_config) layout = QVBoxLayout() - # model = MediaFileTableModel(db_configuration, self) self.data.append(model) - media_file_tab.setLayout(layout) + data_tab.setLayout(layout) table_view = QTableView() table_view.setModel(model) layout.addLayout(table_config.get_filter_layout()) layout.addWidget(table_view) - return media_file_tab + model.refresh() + return data_tab if __name__ == '__main__': diff --git a/gui/model_config.py b/gui/model_config.py index a9c6702..ba3e510 100644 --- a/gui/model_config.py +++ b/gui/model_config.py @@ -1,50 +1,30 @@ import mariadb from PySide6.QtWidgets import QHBoxLayout, QCheckBox +from data import KontorDB + class KontorModelConfig: - def __init__(self, db_config, main_window, table_name: str): + def __init__(self, kontor_db: KontorDB, main_window, table_name: str): self.header = {} self.filter = {} self.main_window = main_window self._table = table_name self._table_id = None - self.db_conn = mariadb.connect( - host=db_config['mariadb']['host'], - port=db_config['mariadb']['port'], - user=db_config['mariadb']['user'], - password=db_config['mariadb']['password'], - database=db_config['mariadb']['database'] - ) + self.kontor_db = kontor_db self.get_table_config() def get_table_id(self): if self._table_id is not None: return - cursor = self.db_conn.cursor() - cursor.execute("SELECT id, created_date, last_modified_date FROM meta_data_table WHERE table_name=?", (self._table, )) - rows = cursor.fetchall() - if len(rows) == 1: - self._table_id = rows[0][0] + 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() - cursor = self.db_conn.cursor() - cursor.execute("SELECT column_name, column_order, column_label FROM meta_data_column WHERE table_id=? AND is_shown is true ORDER bY column_order", (self._table_id, )) - rows = cursor.fetchall() - self.header.clear() - order = 0 - for (column_name, column_order, column_label) in rows: - self.header[order] = { 'column': column_name, 'label': column_label, 'order': column_order} - order += 1 - # print(f"retrieved {len(rows)} columns, set {len(self.header)} headers") - cursor.execute("SELECT column_name, filter_label from meta_data_column WHERE table_id=? AND show_filter is true", (self._table_id, )) - rows = cursor.fetchall() - for row in rows: - self.filter[row[0]] = {'label': row[1], 'widget': None} - # print(f"retrieved {len(rows)} filters: {self.filter}") + self.header = self.kontor_db.get_column_meta_data(self._table_id) + self.filter = self.kontor_db.get_filters(self._table_id) def get_filter(self) -> str: filter_rule = "" @@ -61,16 +41,10 @@ class KontorModelConfig: # print(f"{filter_rule=}") return filter_rule - def get_statement(self) -> str: - filter_rule = self.get_filter() - # self.get_table_config() - columns = "" - for index, column in self.header.items(): - if index > 0: - columns += ", " - columns += column['column'] - statement = f"SELECT {columns} FROM media_file {filter_rule}" - return statement + def get_data(self) -> list: + data = self.kontor_db.get_data(self._table, self.header, self.get_filter()) + print(f"KontorModelConfig.get_data: {len(data)}") + return data def get_filter_layout(self) -> QHBoxLayout: filter_layout = QHBoxLayout() diff --git a/gui/table_model.py b/gui/table_model.py index 81eec89..a28e1cc 100644 --- a/gui/table_model.py +++ b/gui/table_model.py @@ -12,24 +12,22 @@ class KontorTableModel(QAbstractTableModel): super().__init__() self._main_window = model_config.main_window self._config = model_config - self._data = None + self._data = [] def refresh(self): - data = [] - cursor = self._config.db_conn.cursor() - cursor.execute(self._config.get_statement()) - rows = cursor.fetchall() - # print(len(rows)) - if len(rows) > 0: + data = self._config.get_data() + count = 0 + # print(data) + if data is not None: self.beginResetModel() - for row in rows: - data.append(list(row)) + self._data.clear() self._data = data self.endResetModel() - else: - self._data = None + count = len(data) + # print(data) + # print(self._data) self.layoutChanged.emit() - self._main_window.statusBar.showMessage(f"{len(rows)} Einträge geladen", 3000) + self._main_window.statusBar.showMessage(f"{count} Einträge geladen", 3000) def rowCount(self, parent=QModelIndex()): # The length of the outer list. @@ -39,7 +37,7 @@ class KontorTableModel(QAbstractTableModel): def headerData(self, col, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return self._config.header[col]['column'] + return self._config.header[col]['label'] if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole: return str(col+1) diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java index de81e3b..81c6c6a 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java @@ -128,22 +128,22 @@ public class SetupModuleAdmin implements ApplicationListener Date: Wed, 8 Jan 2025 08:05:09 +0100 Subject: [PATCH 10/26] prevent NPE --- .../java/de/thpeetz/kontor/admin/services/MetaDataService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java index 904a7e1..3f1c773 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java @@ -54,7 +54,7 @@ public class MetaDataService { log.debug("columnModifier has to be changed to {}", columnModifier); column.setColumnModifier(columnModifier); } - if (!column.getIsShown().equals(isShown)) { + if (isShown != null && !isShown.equals(column.getIsShown())) { log.debug("isShown has to be change to {}}", isShown); column.setIsShown(isShown); } From 8d31a926926fd10e8357ec6565625f09388f55ef Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 10:39:52 +0100 Subject: [PATCH 11/26] improve view of meta data by using icons for boolean and add search --- .../kontor/admin/SetupModuleAdmin.java | 2 +- .../kontor/admin/data/MetaDataColumn.java | 4 + .../admin/data/MetaDataColumnRepository.java | 6 + .../admin/services/MetaDataService.java | 13 +- .../kontor/admin/views/MetaDataForm.java | 14 ++- .../kontor/admin/views/MetaDataView.java | 117 +++++++++++++++++- .../media/services/MediaFileService.java | 4 +- 7 files changed, 143 insertions(+), 17 deletions(-) diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java index 81c6c6a..4ac89e0 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java @@ -177,7 +177,7 @@ public class SetupModuleAdmin implements ApplicationListener { List findByTable(MetaDataTable table); + + @Query("select m from MetaDataColumn m " + + "where lower(m.columnName) like lower(concat('%', :searchTerm, '%')) or lower(m.columnLabel) like lower(concat('%', :searchTerm, '%'))") + List search(@Param("searchTerm") String searchTerm); } diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java index 3f1c773..98ae1c3 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/services/MetaDataService.java @@ -36,7 +36,7 @@ public class MetaDataService { public void getColumn(MetaDataTable table, String columnName, String columnSyncName, String columnType, String columnModifier, Integer columnOrder, Boolean isShown, String columnLabel, Boolean showFilter, String filterLabel) { if (table.getTableColumns().stream().anyMatch(column -> column.getColumnName().equals(columnName))) { - log.info("Column {} with name {} of table {} found, check Values", columnOrder, columnName, table.getTableName()); + log.debug("Column {} with name {} of table {} found, check Values", columnOrder, columnName, table.getTableName()); MetaDataColumn column = table.getTableColumns().get(columnOrder.intValue()-1); if (!column.getColumnName().equals(columnName)) { log.debug("columnName has to be changed to {}", columnName); @@ -85,8 +85,15 @@ public class MetaDataService { } } - public List findAllMetaDataColumns() { - return metaDataColumnRepository.findAll(); + public List findAllMetaDataColumns(String stringFilter) { + if (stringFilter == null || stringFilter.isEmpty()) { + log.debug("Found " + metaDataColumnRepository.count()+ " entries"); + return metaDataColumnRepository.findAll(); + } else { + List results = metaDataColumnRepository.search(stringFilter); + log.debug("Found " + results.size() + " entries"); + return results; + } } public void deleteMetaDataColumn(MetaDataColumn metaDataColumn) { diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java index 19d4459..b65b099 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java @@ -26,7 +26,9 @@ public class MetaDataForm extends FormLayout { TextField columnModifier = new TextField("Column Modifier"); IntegerField columnOrder = new IntegerField("Column Order"); Checkbox isShown = new Checkbox("Is Shown"); + TextField columnLabel = new TextField("Column Label"); Checkbox showFilter = new Checkbox("Show Filter"); + TextField filterLabel = new TextField("Filter Label"); Button save = new com.vaadin.flow.component.button.Button("Save"); Button delete = new com.vaadin.flow.component.button.Button("Delete"); @@ -40,12 +42,12 @@ public class MetaDataForm extends FormLayout { table.setItems(tables); table.setItemLabelGenerator(MetaDataTable::getTableName); - add(table, 2); - add(columnName, 2); - add(columnSyncName, 2); - add(columnModifier, 2); - add(columnOrder, 2); - add(isShown, showFilter, createButtonsLayout()); + add(table, columnName, columnSyncName, columnModifier, columnOrder); + add(isShown, columnLabel); + isShown.addClickListener(click -> columnLabel.setEnabled(isShown.getValue())); + add(showFilter, filterLabel); + showFilter.addClickListener(click -> filterLabel.setEnabled(showFilter.getValue())); + add(createButtonsLayout()); } private HorizontalLayout createButtonsLayout() { diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java index f3b2509..780126a 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java @@ -2,9 +2,18 @@ package de.thpeetz.kontor.admin.views; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.contextmenu.ContextMenu; +import com.vaadin.flow.component.contextmenu.MenuItem; import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.GridSortOrder; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.provider.SortDirection; +import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.spring.annotation.SpringComponent; @@ -13,9 +22,13 @@ import de.thpeetz.kontor.admin.data.MetaDataColumn; import de.thpeetz.kontor.admin.services.MetaDataService; import de.thpeetz.kontor.common.views.MainLayout; import jakarta.annotation.security.RolesAllowed; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Scope; +import java.util.ArrayList; +import java.util.List; + @Slf4j @SpringComponent @Scope("prototype") @@ -24,10 +37,56 @@ import org.springframework.context.annotation.Scope; @PageTitle("Meta Data | Admin | Kontor") public class MetaDataView extends VerticalLayout { - Grid grid = new Grid<>(MetaDataColumn.class); + Grid grid = new Grid<>(MetaDataColumn.class, false); + Grid.Column idColumn = grid.addColumn(MetaDataColumn::getId) + .setHeader("ID").setResizable(true).setSortable(true); + Grid.Column createdColumn = grid.addColumn(MetaDataColumn::getCreatedDate) + .setHeader("Erstellt").setResizable(true).setSortable(true); + Grid.Column modifiedColumn = grid.addColumn(MetaDataColumn::getLastModifiedDate) + .setHeader("Geändert").setResizable(true).setSortable(true); + Grid.Column versionColumn = grid.addColumn(MetaDataColumn::getVersion) + .setHeader("Version").setResizable(true).setSortable(true); + Grid.Column tableColumn = grid.addColumn(MetaDataColumn::getTableName) + .setHeader("Table").setResizable(true).setSortable(true); + Grid.Column columnNameColumn = grid.addColumn(MetaDataColumn::getColumnName) + .setHeader("Column Name").setResizable(true).setSortable(true); + Grid.Column columnSyncNameColumn = grid.addColumn(MetaDataColumn::getColumnSyncName) + .setHeader("Column Sync Name").setResizable(true).setSortable(true); + Grid.Column columnTypeColumn = grid.addColumn(MetaDataColumn::getColumnType) + .setHeader("Column Type").setResizable(true).setSortable(true); + Grid.Column columnModifierColumn = grid.addColumn(MetaDataColumn::getColumnModifier) + .setHeader("Column Modifier").setResizable(true).setSortable(true); + Grid.Column columnOrderColumn = grid.addColumn(MetaDataColumn::getColumnOrder) + .setHeader("Column Order").setResizable(true).setSortable(true); + Grid.Column isShownColumn = grid.addComponentColumn(metaDataColumn -> createStatusIcon(metaDataColumn.getIsShown())). + setHeader("Anzeige?").setWidth("6rem").setSortable(true); + Grid.Column columnLabelColumn = grid.addColumn(MetaDataColumn::getColumnLabel) + .setHeader("Spaltenname").setResizable(true).setSortable(true); + Grid.Column showFilterColumn = grid.addComponentColumn(metaDataColumn -> createStatusIcon(metaDataColumn.getShowFilter())). + setHeader("Zeige Filter").setWidth("6rem").setSortable(true); + Grid.Column filterLabelColumn = grid.addColumn(MetaDataColumn::getFilterLabel) + .setHeader("Filter Name").setResizable(true).setSortable(true); + TextField searchField = new TextField(); + @Getter MetaDataForm form; MetaDataService service; + private static class ColumnToggleContextMenu extends ContextMenu { + public ColumnToggleContextMenu(Component target) { + super(target); + setOpenOnClick(true); + } + + void addColumnToggleItem(String label, Grid.Column column) { + MenuItem menuItem = this.addItem(label, e -> { + column.setVisible(e.getSource().isChecked()); + }); + menuItem.setCheckable(true); + menuItem.setChecked(column.isVisible()); + menuItem.setKeepOpen(true); + } + } + public MetaDataView(MetaDataService service) { this.service = service; addClassName("metadata-view"); @@ -42,8 +101,17 @@ public class MetaDataView extends VerticalLayout { private void configureGrid() { grid.addClassName("metadata-grid"); grid.setSizeFull(); - grid.setColumns("table.tableName", "columnName", "columnSyncName", "columnModifier", "columnOrder", "isShown", "showFilter"); + //grid.setColumns("table.tableName", "columnName", "columnSyncName", "columnModifier", "columnOrder", "isShown", "columnLabel", "showFilter", "filterLabel"); grid.getColumns().forEach(col -> col.setAutoWidth(true)); + idColumn.setVisible(false); + createdColumn.setVisible(false); + modifiedColumn.setVisible(false); + versionColumn.setVisible(false); + grid.setMultiSort(true); + List sortOrder = new ArrayList(); + sortOrder.add(new GridSortOrder(tableColumn, SortDirection.ASCENDING)); + sortOrder.add(new GridSortOrder(columnOrderColumn, SortDirection.ASCENDING)); + grid.sort(sortOrder); grid.asSingleSelect().addValueChangeListener(event -> editMetaData(event.getValue())); } @@ -56,6 +124,19 @@ public class MetaDataView extends VerticalLayout { form.addCloseListener(e -> closeEditor()); } + private Icon createStatusIcon(boolean status) { + Icon icon; + if (status) { + icon = VaadinIcon.CHECK.create(); + icon.getElement().getThemeList().add("badge success"); + } else { + icon = VaadinIcon.CLOSE_SMALL.create(); + icon.getElement().getThemeList().add("badge error"); + } + icon.getStyle().set("padding", "var(--lumo-space-xs"); + return icon; + } + private void saveMetaData(MetaDataForm.SaveEvent event) { MetaDataColumn metaDataColumn = event.getMetaDataColumn(); service.saveMetaDataColumn(metaDataColumn); @@ -79,8 +160,34 @@ public class MetaDataView extends VerticalLayout { } private HorizontalLayout getToolbar() { - Button addAuthorizationMaxtrixButton = new Button("Add Meta Data", click -> addMetaDataColumn()); - HorizontalLayout toolbar = new HorizontalLayout(addAuthorizationMaxtrixButton); + searchField.setPlaceholder("Search"); + searchField.setClearButtonVisible(true); + searchField.setPrefixComponent(new Icon(VaadinIcon.SEARCH)); + searchField.setValueChangeMode(ValueChangeMode.EAGER); + searchField.addValueChangeListener(e -> updateList()); + + Button addMetaDataButton = new Button("Add Meta Data"); + addMetaDataButton.addClickListener(click -> addMetaDataColumn()); + + Button menuButton = new Button("Show/Hide Columns"); + menuButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + MetaDataView.ColumnToggleContextMenu columnToggleContextMenu = new MetaDataView.ColumnToggleContextMenu(menuButton); + columnToggleContextMenu.addColumnToggleItem("ID", idColumn); + columnToggleContextMenu.addColumnToggleItem("Erstellt", createdColumn); + columnToggleContextMenu.addColumnToggleItem("Geändert", modifiedColumn); + columnToggleContextMenu.addColumnToggleItem(versionColumn.getHeaderText(), versionColumn); + columnToggleContextMenu.addColumnToggleItem(tableColumn.getHeaderText(), tableColumn); + columnToggleContextMenu.addColumnToggleItem(columnNameColumn.getHeaderText(), columnNameColumn); + columnToggleContextMenu.addColumnToggleItem(columnSyncNameColumn.getHeaderText(), columnSyncNameColumn); + columnToggleContextMenu.addColumnToggleItem(columnTypeColumn.getHeaderText(), columnTypeColumn); + columnToggleContextMenu.addColumnToggleItem(columnModifierColumn.getHeaderText(), columnModifierColumn); + columnToggleContextMenu.addColumnToggleItem(columnOrderColumn.getHeaderText(), columnOrderColumn); + columnToggleContextMenu.addColumnToggleItem(isShownColumn.getHeaderText(), isShownColumn); + columnToggleContextMenu.addColumnToggleItem(columnLabelColumn.getHeaderText(), columnLabelColumn); + columnToggleContextMenu.addColumnToggleItem(showFilterColumn.getHeaderText(), showFilterColumn); + columnToggleContextMenu.addColumnToggleItem(filterLabelColumn.getHeaderText(), filterLabelColumn); + + HorizontalLayout toolbar = new HorizontalLayout(searchField, addMetaDataButton, menuButton); toolbar.addClassName("toolbar"); return toolbar; } @@ -107,6 +214,6 @@ public class MetaDataView extends VerticalLayout { } private void updateList() { - grid.setItems(service.findAllMetaDataColumns()); + grid.setItems(service.findAllMetaDataColumns(searchField.getValue())); } } diff --git a/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java index a7fe0a1..ffb0a24 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java +++ b/springboot/src/main/java/de/thpeetz/kontor/media/services/MediaFileService.java @@ -19,11 +19,11 @@ public class MediaFileService { public List findAllMediaFiles(String stringFilter) { if (stringFilter == null || stringFilter.isEmpty()) { - log.info("Found " + mediaFileRepository.count()+ " entries"); + log.debug("Found " + mediaFileRepository.count()+ " entries"); return mediaFileRepository.findAll(); } else { List results = mediaFileRepository.search(stringFilter); - log.info("Found " + results.size() + " entries"); + log.debug("Found " + results.size() + " entries"); return results; } } From 24e4c0b58e61302d96dba9c9b251f301865db7de Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 14:15:29 +0100 Subject: [PATCH 12/26] import export dialog by adding selection of export type --- gui/dialogs.py | 25 +++++++++++++++++++++++-- gui/kontor.py | 1 + 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/gui/dialogs.py b/gui/dialogs.py index ae69c3d..f7e51d1 100644 --- a/gui/dialogs.py +++ b/gui/dialogs.py @@ -1,5 +1,7 @@ +from pathlib import Path + from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QFileDialog, \ - QGroupBox, QCheckBox + QGroupBox, QCheckBox, QComboBox class ExportKontorDialog(QDialog): @@ -12,17 +14,26 @@ class ExportKontorDialog(QDialog): 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") - layout = QVBoxLayout() + + 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) @@ -30,6 +41,8 @@ class ExportKontorDialog(QDialog): 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) @@ -42,11 +55,19 @@ class ExportKontorDialog(QDialog): 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: diff --git a/gui/kontor.py b/gui/kontor.py index 771c466..30bb3bd 100644 --- a/gui/kontor.py +++ b/gui/kontor.py @@ -114,6 +114,7 @@ class MainWindow(QMainWindow): export_dlg = ExportKontorDialog(self, self.kontor_db) if export_dlg.exec(): 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) else: self.statusBar.showMessage("Export cancelled", 3000) From e078f43cc6596c22a06110a178ce5e15157506ee Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 14:28:18 +0000 Subject: [PATCH 13/26] set correct project id in build.gradle --- springboot/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/springboot/build.gradle b/springboot/build.gradle index 8892faf..0624568 100644 --- a/springboot/build.gradle +++ b/springboot/build.gradle @@ -97,7 +97,7 @@ publishing { repositories { maven { name = "gitlabPackageRegistry" - url = uri("https://gitlab.com/api/v4/projects/62010300/packages/maven") + url = uri("https://gitlab.com/api/v4/projects/64726715/packages/maven") credentials(PasswordCredentials) } } From fc4110b11d9456603686dfe7dff26df7e33c492c Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 15:34:11 +0100 Subject: [PATCH 14/26] fix problem in string formatting --- gui/dialogs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/dialogs.py b/gui/dialogs.py index f7e51d1..703baeb 100644 --- a/gui/dialogs.py +++ b/gui/dialogs.py @@ -57,13 +57,13 @@ class ExportKontorDialog(QDialog): def change_export_type(self, text): self.current_export_type = text - self.label.setText(f"Export DB to data.{self.export_options[text]["ext"]}") + 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"]}") + 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) From 6923b3e5eeb76dac76ff8d0498fb73af2951f3cd Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 22:31:20 +0100 Subject: [PATCH 15/26] import from kontor-flask --- flask/config.py | 47 ++ flask/docs/Makefile | 20 + flask/docs/conf.py | 200 +++++++++ flask/docs/index.rst | 20 + flask/kontor/__init__.py | 74 ++++ flask/kontor/admin/__init__.py | 4 + flask/kontor/admin/forms.py | 17 + flask/kontor/admin/views.py | 63 +++ flask/kontor/auth/__init__.py | 4 + flask/kontor/auth/forms.py | 50 +++ flask/kontor/auth/views.py | 79 ++++ flask/kontor/comics/__init__.py | 3 + flask/kontor/comics/forms.py | 31 ++ flask/kontor/comics/models.py | 51 +++ flask/kontor/comics/views.py | 222 ++++++++++ flask/kontor/home/__init__.py | 3 + flask/kontor/home/views.py | 39 ++ flask/kontor/library/__init__.py | 3 + flask/kontor/library/forms.py | 35 ++ flask/kontor/library/models.py | 55 +++ flask/kontor/library/views.py | 228 ++++++++++ flask/kontor/models.py | 66 +++ flask/kontor/office/__init__.py | 3 + flask/kontor/static/css/style.css | 126 ++++++ flask/kontor/templates/admin/users/user.html | 21 + flask/kontor/templates/admin/users/users.html | 58 +++ flask/kontor/templates/auth/login.html | 18 + flask/kontor/templates/auth/register.html | 14 + flask/kontor/templates/base.html | 107 +++++ flask/kontor/templates/comics/artists.html | 58 +++ flask/kontor/templates/comics/comics.html | 66 +++ flask/kontor/templates/comics/publishers.html | 58 +++ .../templates/home/admin_dashboard.html | 31 ++ flask/kontor/templates/home/dashboard.html | 20 + flask/kontor/templates/home/index.html | 20 + flask/kontor/templates/library/authors.html | 58 +++ flask/kontor/templates/library/books.html | 74 ++++ .../kontor/templates/library/publishers.html | 58 +++ flask/kontor/templates/simpleform.html | 21 + .../kontor/templates/tysc/manufacturers.html | 58 +++ flask/kontor/templates/tysc/players.html | 60 +++ flask/kontor/templates/tysc/positions.html | 62 +++ flask/kontor/templates/tysc/sports.html | 58 +++ flask/kontor/templates/tysc/teams.html | 60 +++ flask/kontor/tysc/__init__.py | 377 ++++++++++++++++ flask/kontor/tysc/forms.py | 76 ++++ flask/kontor/tysc/models.py | 155 +++++++ flask/kontor/tysc/views.py | 405 ++++++++++++++++++ flask/kontor/version.py | 2 + flask/pylint.cfg | 382 +++++++++++++++++ flask/requirements.txt | 11 + flask/tests/__init__.py | 38 ++ flask/tests/test_comics_model.py | 51 +++ flask/tests/test_comics_view.py | 44 ++ flask/tests/test_config.txt | 4 + flask/tests/test_kontor_model.py | 14 + flask/tests/test_kontor_view.py | 58 +++ flask/tests/test_library_model.py | 49 +++ flask/tests/test_library_view.py | 44 ++ flask/tests/test_tysc_model.py | 53 +++ flask/tests/test_tysc_view.py | 110 +++++ 61 files changed, 4296 insertions(+) create mode 100644 flask/config.py create mode 100644 flask/docs/Makefile create mode 100644 flask/docs/conf.py create mode 100644 flask/docs/index.rst create mode 100644 flask/kontor/__init__.py create mode 100644 flask/kontor/admin/__init__.py create mode 100644 flask/kontor/admin/forms.py create mode 100644 flask/kontor/admin/views.py create mode 100644 flask/kontor/auth/__init__.py create mode 100644 flask/kontor/auth/forms.py create mode 100644 flask/kontor/auth/views.py create mode 100644 flask/kontor/comics/__init__.py create mode 100644 flask/kontor/comics/forms.py create mode 100644 flask/kontor/comics/models.py create mode 100644 flask/kontor/comics/views.py create mode 100644 flask/kontor/home/__init__.py create mode 100644 flask/kontor/home/views.py create mode 100644 flask/kontor/library/__init__.py create mode 100644 flask/kontor/library/forms.py create mode 100644 flask/kontor/library/models.py create mode 100644 flask/kontor/library/views.py create mode 100644 flask/kontor/models.py create mode 100644 flask/kontor/office/__init__.py create mode 100644 flask/kontor/static/css/style.css create mode 100644 flask/kontor/templates/admin/users/user.html create mode 100644 flask/kontor/templates/admin/users/users.html create mode 100644 flask/kontor/templates/auth/login.html create mode 100644 flask/kontor/templates/auth/register.html create mode 100644 flask/kontor/templates/base.html create mode 100644 flask/kontor/templates/comics/artists.html create mode 100644 flask/kontor/templates/comics/comics.html create mode 100644 flask/kontor/templates/comics/publishers.html create mode 100644 flask/kontor/templates/home/admin_dashboard.html create mode 100644 flask/kontor/templates/home/dashboard.html create mode 100644 flask/kontor/templates/home/index.html create mode 100644 flask/kontor/templates/library/authors.html create mode 100644 flask/kontor/templates/library/books.html create mode 100644 flask/kontor/templates/library/publishers.html create mode 100644 flask/kontor/templates/simpleform.html create mode 100644 flask/kontor/templates/tysc/manufacturers.html create mode 100644 flask/kontor/templates/tysc/players.html create mode 100644 flask/kontor/templates/tysc/positions.html create mode 100644 flask/kontor/templates/tysc/sports.html create mode 100644 flask/kontor/templates/tysc/teams.html create mode 100644 flask/kontor/tysc/__init__.py create mode 100644 flask/kontor/tysc/forms.py create mode 100644 flask/kontor/tysc/models.py create mode 100644 flask/kontor/tysc/views.py create mode 100644 flask/kontor/version.py create mode 100644 flask/pylint.cfg create mode 100644 flask/requirements.txt create mode 100644 flask/tests/__init__.py create mode 100644 flask/tests/test_comics_model.py create mode 100644 flask/tests/test_comics_view.py create mode 100644 flask/tests/test_config.txt create mode 100644 flask/tests/test_kontor_model.py create mode 100644 flask/tests/test_kontor_view.py create mode 100644 flask/tests/test_library_model.py create mode 100644 flask/tests/test_library_view.py create mode 100644 flask/tests/test_tysc_model.py create mode 100644 flask/tests/test_tysc_view.py diff --git a/flask/config.py b/flask/config.py new file mode 100644 index 0000000..4ea5e78 --- /dev/null +++ b/flask/config.py @@ -0,0 +1,47 @@ +# config.py + + +class Config(object): + """ + Common configurations + """ + + # Put any configurations here that are common across all environments + host = '127.0.0.1' + port = 8500 + database = 'kontor' + + +class DevelopmentConfig(Config): + """ + Development configurations + """ + + DEBUG = True + host = '0.0.0.0' + database = 'kontor_dev' + + +class ProductionConfig(Config): + """ + Production configurations + """ + + DEBUG = False + +class TestingConfig(Config): + """ + Testing configurations + """ + + TESTING = True + DEBUG = True + port = 8600 + database = 'kontor_test' + +app_config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig +} + diff --git a/flask/docs/Makefile b/flask/docs/Makefile new file mode 100644 index 0000000..d7218b4 --- /dev/null +++ b/flask/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = KontorFlask +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/flask/docs/conf.py b/flask/docs/conf.py new file mode 100644 index 0000000..7278f2d --- /dev/null +++ b/flask/docs/conf.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Kontor Flask documentation build configuration file, created by +# sphinx-quickstart on Mon Nov 6 08:53:04 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.mathjax', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Kontor Flask' +copyright = '2017, Thomas Peetz' +author = 'Thomas Peetz' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.1' +# The full version, including alpha/beta/rc tags. +release = '0.0.7' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'de' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# This is required for the alabaster theme +# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars +html_sidebars = { + '**': [ + 'relations.html', # needs 'show_related': True theme option to display + 'searchbox.html', + ] +} + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'KontorFlaskdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'KontorFlask.tex', 'Kontor Flask Documentation', + 'Thomas Peetz', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'kontorflask', 'Kontor Flask Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'KontorFlask', 'Kontor Flask Documentation', + author, 'KontorFlask', 'One line description of project.', + 'Miscellaneous'), +] + + + +# -- Options for Epub output ---------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/flask/docs/index.rst b/flask/docs/index.rst new file mode 100644 index 0000000..ca4df98 --- /dev/null +++ b/flask/docs/index.rst @@ -0,0 +1,20 @@ +.. Kontor Flask documentation master file, created by + sphinx-quickstart on Mon Nov 6 08:53:04 2017. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Kontor Flask's documentation! +======================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/flask/kontor/__init__.py b/flask/kontor/__init__.py new file mode 100644 index 0000000..961e132 --- /dev/null +++ b/flask/kontor/__init__.py @@ -0,0 +1,74 @@ +""" +Module kontor implements Kontor application. +""" + +from flask import Flask +from flask_bootstrap import Bootstrap +from flask_login import LoginManager +from pymodm import connect + +# local imports +from config import app_config +from .version import __version__ + + +_LOGIN_MANAGER_ = LoginManager() + + +def get_host(config_name): + """ + Returns host address from configuration. + :param config_name: + :return: host address + """ + host = app_config[config_name].host + return host + + +def get_port(config_name): + """ + Returns port number from configuration. + :param config_name: + :return: port number + """ + port = app_config[config_name].port + return port + + +def create_app(config_name): + """ + Returns Flask application. + :param config_name: + :return: Flask application + """ + app = Flask(__name__, instance_relative_config=True) + app.config.from_object(app_config[config_name]) + app.config.from_pyfile('config.py') + + database = "mongodb://localhost:27017/{}".format(app_config[config_name].database) + connect(database, alias="kontor") + + Bootstrap(app) + _LOGIN_MANAGER_.init_app(app) + _LOGIN_MANAGER_.login_message = "You must be logged in to access this page." + _LOGIN_MANAGER_.login_view = "auth.login" + + from .admin.views import ADMIN as ADMIN_BLUEPRINT + app.register_blueprint(ADMIN_BLUEPRINT, url_prefix='/admin') + + from .auth.views import AUTH as AUTH_BLUEPRINT + app.register_blueprint(AUTH_BLUEPRINT) + + from .home.views import HOME as HOME_BLUEPRINT + app.register_blueprint(HOME_BLUEPRINT) + + from .comics.views import COMIC as COMIC_BLUEPRINT + app.register_blueprint(COMIC_BLUEPRINT, url_prefix='/comics') + + from .library.views import LIBRARY as LIBRARY_BLUEPRINT + app.register_blueprint(LIBRARY_BLUEPRINT, url_prefix='/library') + + from .tysc.views import TYSC as TYSC_BLUEPRINT + app.register_blueprint(TYSC_BLUEPRINT, url_prefix='/tysc') + + return app diff --git a/flask/kontor/admin/__init__.py b/flask/kontor/admin/__init__.py new file mode 100644 index 0000000..ca00a4b --- /dev/null +++ b/flask/kontor/admin/__init__.py @@ -0,0 +1,4 @@ +""" +Module admin implements administration functions. +""" +from . import views diff --git a/flask/kontor/admin/forms.py b/flask/kontor/admin/forms.py new file mode 100644 index 0000000..d05cfaf --- /dev/null +++ b/flask/kontor/admin/forms.py @@ -0,0 +1,17 @@ +""" +Define form to edit users +""" +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField +from wtforms.validators import DataRequired + + +class UserEditForm(FlaskForm): + """ + Form for admin to edit users + """ + email = StringField('Email', validators=[DataRequired()]) + username = StringField('Username', validators=[DataRequired()]) + first_name = StringField('First Name', validators=[DataRequired()]) + last_name = StringField('Last Name', validators=[DataRequired()]) + submit = SubmitField('Submit') diff --git a/flask/kontor/admin/views.py b/flask/kontor/admin/views.py new file mode 100644 index 0000000..e96d8f0 --- /dev/null +++ b/flask/kontor/admin/views.py @@ -0,0 +1,63 @@ +""" +Define routing rules for managing users +""" +from flask import abort, Blueprint, flash, redirect, render_template, url_for +from flask_login import current_user, login_required + +from .forms import UserEditForm +from ..models import User + +ADMIN = Blueprint('admin', __name__) + + +def check_admin(): + """ + Prevent non-admins from accessing the page + """ + if not current_user.is_admin: + abort(403) + + +# User Views +@ADMIN.route('/users') +@login_required +def list_users(): + """ + List all users + """ + check_admin() + + users = User.objects.all() + return render_template('admin/users/users.html', + users=users, title='Users') + + +@ADMIN.route('/users/edit/', methods=['GET', 'POST']) +@login_required +def edit_user(user_id): + """ + Edit an user. + """ + check_admin() + + user = User.objects.get({'username': user_id}) + + # prevent admin from being assigned a department or role + if user.is_admin: + abort(403) + + form = UserEditForm(obj=user) + if form.validate_on_submit(): + user.email = form.email.data + user.username = form.username.data + user.first_name = form.first_name.data + user.last_name = form.last_name.data + user.save() + flash('You have successfully edited an user.') + + # redirect to the roles page + return redirect(url_for('admin.list_users')) + + return render_template('admin/users/user.html', + user=user, form=form, + title='Edit User') diff --git a/flask/kontor/auth/__init__.py b/flask/kontor/auth/__init__.py new file mode 100644 index 0000000..2d9b1ca --- /dev/null +++ b/flask/kontor/auth/__init__.py @@ -0,0 +1,4 @@ +""" +Module auth implements authentication functions. +""" +from . import views diff --git a/flask/kontor/auth/forms.py b/flask/kontor/auth/forms.py new file mode 100644 index 0000000..6e4d6dd --- /dev/null +++ b/flask/kontor/auth/forms.py @@ -0,0 +1,50 @@ +""" +Contains forms for authentication. +""" +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField, SubmitField, ValidationError +from wtforms.validators import DataRequired, Email, EqualTo + +from ..models import User + + +class RegistrationForm(FlaskForm): + """ + Form for users to create new account + """ + email = StringField('Email', validators=[DataRequired(), Email()]) + username = StringField('Username', validators=[DataRequired()]) + first_name = StringField('First Name', validators=[DataRequired()]) + last_name = StringField('Last Name', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired(), + EqualTo('confirm_password') + ]) + confirm_password = PasswordField('Confirm Password') + submit = SubmitField('Register') + + def validate_email(self, field): + """ + Check if email is already in use. + :param field: + :return: + """ + if User.objects.raw({'email': field.data}).count() > 0: + raise ValidationError('Email is already in use.') + + def validate_username(self, field): + """ + check if username is already in use. + :param field: + :return: + """ + if User.objects.raw({'username': field.data}).count() > 0: + raise ValidationError('Username is already in use.') + + +class LoginForm(FlaskForm): + """ + Form for users to login + """ + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + submit = SubmitField('Login') diff --git a/flask/kontor/auth/views.py b/flask/kontor/auth/views.py new file mode 100644 index 0000000..217ae18 --- /dev/null +++ b/flask/kontor/auth/views.py @@ -0,0 +1,79 @@ +""" +Define routing rules for registering users and login and logout. +""" +from flask import Blueprint, flash, redirect, render_template, url_for +from flask_login import login_required, login_user, logout_user +from .forms import LoginForm, RegistrationForm +from ..models import User + +AUTH = Blueprint('auth', __name__) + + +@AUTH.route('/register', methods=['GET', 'POST']) +def register(): + """ + Handle requests to the /register route + Add an employee to the database through the registration form + """ + form = RegistrationForm() + if form.validate_on_submit(): + user = User() + user.email = form.email.data + user.username = form.username.data + user.first_name = form.first_name.data + user.last_name = form.last_name.data + user.password = form.password.data + # add employee to the database + user.save() + flash('You have successfully registered! You may now login.') + + # redirect to the login page + return redirect(url_for('auth.login')) + + # load registration template + return render_template('auth/register.html', form=form, title='Register') + + +@AUTH.route('/login', methods=['GET', 'POST']) +def login(): + """ + Handle requests to the /login route + Validate given email with matching password + :return: + """ + form = LoginForm() + if form.validate_on_submit(): + + # check whether employee exists in the database and whether + # the password entered matches the password in the database + user = User.objects.get({'email': form.email.data}) + if user is not None and user.verify_password( + form.password.data): + # log employee in + login_user(user) + + # redirect to the appropriate dashboard page + if user.is_admin: + return redirect(url_for('home.admin_dashboard')) + else: + return redirect(url_for('home.dashboard')) + + # when login details are incorrect + else: + flash('Invalid email or password.') + + # load login template + return render_template('auth/login.html', form=form, title='Login') + +@AUTH.route('/logout') +@login_required +def logout(): + """ + Handle requests to the /logout route + Log an employee out through the logout link + """ + logout_user() + flash('You have successfully been logged out.') + + # redirect to the login page + return redirect(url_for('auth.login')) diff --git a/flask/kontor/comics/__init__.py b/flask/kontor/comics/__init__.py new file mode 100644 index 0000000..e947d82 --- /dev/null +++ b/flask/kontor/comics/__init__.py @@ -0,0 +1,3 @@ +""" +Define routing rules for comic related information +""" diff --git a/flask/kontor/comics/forms.py b/flask/kontor/comics/forms.py new file mode 100644 index 0000000..0bae45f --- /dev/null +++ b/flask/kontor/comics/forms.py @@ -0,0 +1,31 @@ +"""Define form to edit publisher, artists and comics""" +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, BooleanField, SelectField +from wtforms.validators import DataRequired +from bson import ObjectId + + +class PublisherForm(FlaskForm): + """ + Form to add and edit a Comic publisher + """ + name = StringField('Name', validators=[DataRequired()]) + submit = SubmitField('Submit') + + +class ArtistForm(FlaskForm): + """ + Form to add and edit a Comic publisher + """ + name = StringField('Name', validators=[DataRequired()]) + submit = SubmitField('Submit') + + +class ComicForm(FlaskForm): + """ + Form to add and edit Comics + """ + title = StringField('Title', validators=[DataRequired()]) + publisher = SelectField('Publisher', coerce=ObjectId) + current_order = BooleanField('Current Order') + submit = SubmitField('Submit') diff --git a/flask/kontor/comics/models.py b/flask/kontor/comics/models.py new file mode 100644 index 0000000..e39e744 --- /dev/null +++ b/flask/kontor/comics/models.py @@ -0,0 +1,51 @@ +"""This modules declares the model for Comic related information.""" + +from flask import current_app +from pymongo.write_concern import WriteConcern +from pymodm import MongoModel, fields + + +class Publisher(MongoModel): + """Class Publisher represents a publisher of a comic.""" + name = fields.CharField() + + def __str__(self): + return "Publisher({})".format(self.name) + + @property + def comics(self): + """ + Return list of comics which has reference to this publisher + :return: + """ + comics = Comic.objects.raw({'publisher': self.pk}) + current_app.logger.debug(comics) + return comics + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Artist(MongoModel): + """Class Artist represents a comic artist.""" + name = fields.CharField() + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Comic(MongoModel): + """Class Comic represents a comic.""" + title = fields.CharField() + publisher = fields.ReferenceField(Publisher) + current_order = fields.BooleanField(default=False) + completed = fields.BooleanField(default=False) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) diff --git a/flask/kontor/comics/views.py b/flask/kontor/comics/views.py new file mode 100644 index 0000000..090b032 --- /dev/null +++ b/flask/kontor/comics/views.py @@ -0,0 +1,222 @@ +""" +Define routing rules for comics, publisher and artists +""" +from flask import Blueprint, flash, redirect, render_template, url_for +from flask_login import login_required +from bson import ObjectId +from pymongo.errors import PyMongoError +from .forms import ComicForm, PublisherForm, ArtistForm +from .models import Comic, Publisher, Artist + + +COMIC = Blueprint('comic', __name__) + + +@COMIC.route('/artists') +@login_required +def list_artists(): + """ + List all artists + :return: + """ + artists = Artist.objects.all() + return render_template('comics/artists.html', + artists=artists, title="Artists") + + +@COMIC.route('/artists/edit/', methods=['GET', 'POST']) +@login_required +def edit_artist(artist_id): + """ + Edit a comic artist + """ + artist = Artist.objects.get({'_id': ObjectId(artist_id)}) + form = ArtistForm(obj=artist) + if form.validate_on_submit(): + artist.name = form.name.data + artist.save() + flash('You have successfully edited the artist.') + return redirect(url_for('comic.list_artists')) + form.name.data = artist.name + return render_template('simpleform.html', action="Edit", + form=form, title="Edit Artist") + + +@COMIC.route('/artists/add', methods=['GET', 'POST']) +@login_required +def add_artist(): + """ + Add a artist + :return: + """ + form = ArtistForm() + if form.validate_on_submit(): + artist = Artist() + artist.name = form.name.data + try: + # add publisher to the database + artist.save() + flash('You have successfully added a new artist.') + except PyMongoError: + # in case publisher name already exists + flash('Error: artist name already exists.') + return redirect(url_for('comic.list_artists')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Artist") + + +@COMIC.route('/artists/delete/', methods=['GET', 'POST']) +@login_required +def delete_artist(artist_id): + """ + Delete a comic artist + :param artist_id: + :return: + """ + artist = Artist.objects.raw({'_id': ObjectId(artist_id)}) + if artist: + artist.delete() + flash('You have successfully deleted the comic artist.') + return redirect(url_for('comic.list_artists')) + + +@COMIC.route('/publishers') +@login_required +def list_publishers(): + """ + List all publishers + :return: + """ + publishers = Publisher.objects.all() + return render_template('comics/publishers.html', + publishers=publishers, title="Publishers") + + +@COMIC.route('/publishers/add', methods=['GET', 'POST']) +@login_required +def add_publisher(): + """ + Add a publisher to the database + :return: + """ + form = PublisherForm() + if form.validate_on_submit(): + publisher = Publisher() + publisher.name = form.name.data + try: + # add publisher to the database + publisher.save() + flash('You have successfully added a new publisher.') + except PyMongoError: + # in case publisher name already exists + flash('Error: publisher name already exists.') + return redirect(url_for('comic.list_publishers')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Publisher") + + +@COMIC.route('/publishers/edit/', methods=['GET', 'POST']) +@login_required +def edit_publisher(publisher_id): + """ + Edit a publisher + """ + publisher = Publisher.objects.get({'_id': ObjectId(publisher_id)}) + form = PublisherForm(obj=publisher) + if form.validate_on_submit(): + publisher.name = form.name.data + publisher.save() + flash('You have successfully edited the publisher.') + return redirect(url_for('comic.list_publishers')) + form.name.data = publisher.name + return render_template('simpleform.html', action="Edit", + form=form, title="Edit Publisher") + + +@COMIC.route('/publishers/delete/', methods=['GET', 'POST']) +@login_required +def delete_publisher(publisher_id): + """ + Delete a publisher + :param publisher_id: ObjectId of publisher + :return: + """ + publisher = Publisher.objects.raw({'_id': ObjectId(publisher_id)}) + if publisher: + publisher.delete() + flash('You have successfully deleted the publisher.') + return redirect(url_for('comic.list_publishers')) + + +@COMIC.route('/comics') +@login_required +def list_comics(): + """ + List all comics + :return: + """ + comics = Comic.objects.all() + return render_template('comics/comics.html', + comics=comics, title="Comics") + + +@COMIC.route('/comics/edit/', methods=['GET', 'POST']) +@login_required +def edit_comic(comic_id): + """ + Edit a comic + """ + comic = Comic.objects.get({'_id': ObjectId(comic_id)}) + form = ComicForm(obj=comic) + form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] + form.publisher.default = comic.publisher.pk + form.publisher.process_data(comic.publisher.pk) + if form.validate_on_submit(): + comic.title = form.title.data + comic.current_order = form.current_order.data + comic.publisher = form.publisher.data + comic.save() + flash('You have successfully edited the comic.') + return redirect(url_for('comic.list_comics')) + form.title.data = comic.title + form.current_order.data = comic.current_order + return render_template('simpleform.html', action="Edit", + form=form, title="Edit Comic") + + +@COMIC.route('/comics/add', methods=['GET', 'POST']) +@login_required +def add_comic(): + """ + Add a comic + :return: + """ + form = ComicForm() + form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] + if form.validate_on_submit(): + comic = Comic() + comic.title = form.title.data + comic.publisher = form.publisher.data + try: + comic.save() + flash('You have successfully added a new comic.') + except PyMongoError: + flash('Error: comic title already exists.') + return redirect(url_for('comic.list_comics')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Comic") + + +@COMIC.route('/comics/delete/', methods=['GET', 'POST']) +@login_required +def delete_comic(comic_id): + """ + Delete a comic + :param comic_id: + :return: + """ + comic = Comic.objects.raw({'_id': ObjectId(comic_id)}) + if comic: + comic.delete() + flash('You have successfully deleted the comic.') + return redirect(url_for('comic.list_comics')) diff --git a/flask/kontor/home/__init__.py b/flask/kontor/home/__init__.py new file mode 100644 index 0000000..d4468a9 --- /dev/null +++ b/flask/kontor/home/__init__.py @@ -0,0 +1,3 @@ +""" +Module for the Kontor homepage. +""" diff --git a/flask/kontor/home/views.py b/flask/kontor/home/views.py new file mode 100644 index 0000000..4e4d539 --- /dev/null +++ b/flask/kontor/home/views.py @@ -0,0 +1,39 @@ +""" +Define routing rules for homepage and dashboards +""" + +from flask import Blueprint, render_template, abort +from flask_login import login_required, current_user + +HOME = Blueprint('home', __name__) + + +@HOME.route('/') +def homepage(): + """ + Render the homepage template on the / route + """ + return render_template('home/index.html', title="Welcome") + + +@HOME.route('/dashboard') +@login_required +def dashboard(): + """ + Render the dashboard template on the /dashboard route + """ + return render_template('home/dashboard.html', title="Dashboard") + + +@HOME.route('/admin/dashboard') +@login_required +def admin_dashboard(): + """ + Render the admin_dashboard template on the /admin/dashboard route + :return: + """ + # prevent non-admins from accessing the page + if not current_user.is_admin: + abort(403) + + return render_template('home/admin_dashboard.html', title="Dashboard") diff --git a/flask/kontor/library/__init__.py b/flask/kontor/library/__init__.py new file mode 100644 index 0000000..31eb0bc --- /dev/null +++ b/flask/kontor/library/__init__.py @@ -0,0 +1,3 @@ +""" +Define routing rules for library related information +""" diff --git a/flask/kontor/library/forms.py b/flask/kontor/library/forms.py new file mode 100644 index 0000000..a080781 --- /dev/null +++ b/flask/kontor/library/forms.py @@ -0,0 +1,35 @@ +""" +Define form to edit publisher, artists and books +""" +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, SelectField, IntegerField +from wtforms.validators import DataRequired, Optional, NumberRange +from bson import ObjectId + + +class PublisherForm(FlaskForm): + """ + Form to add and edit a Comic publisher + """ + name = StringField('Name', validators=[DataRequired()]) + submit = SubmitField('Submit') + + +class AuthorForm(FlaskForm): + """ + Form to add and edit a Comic publisher + """ + name = StringField('Name', validators=[DataRequired()]) + submit = SubmitField('Submit') + + +class BookForm(FlaskForm): + """ + Form to add and edit Comics + """ + title = StringField('Title', validators=[DataRequired()]) + author = SelectField('Author', coerce=ObjectId) + publisher = SelectField('Publisher', coerce=ObjectId) + isbn = StringField('ISBN', validators=[Optional()]) + year = IntegerField('Year', validators=[NumberRange(min=1970, max=2050)]) + submit = SubmitField('Submit') diff --git a/flask/kontor/library/models.py b/flask/kontor/library/models.py new file mode 100644 index 0000000..7ca5c58 --- /dev/null +++ b/flask/kontor/library/models.py @@ -0,0 +1,55 @@ +""" +This module declares the model for the library related information. +""" +from pymongo.write_concern import WriteConcern +from pymodm import MongoModel, fields + + +class Publisher(MongoModel): + """ + Class Publisher represents a publisher of a book. + """ + name = fields.CharField() + + def __str__(self): + return "Publisher({})".format(self.name) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Author(MongoModel): + """ + Class Author represents an author of a book. + """ + name = fields.CharField() + + def __str__(self): + return "Author({})".format(self.name) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Book(MongoModel): + """ + Class book represents a book. + """ + title = fields.CharField() + author = fields.ReferenceField(Author) + publisher = fields.ReferenceField(Publisher) + isbn = fields.CharField(blank=True) + year = fields.IntegerField(blank=True) + edition = fields.CharField() + + def __str__(self): + return "Book({})".format(self.title) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) diff --git a/flask/kontor/library/views.py b/flask/kontor/library/views.py new file mode 100644 index 0000000..1b53eee --- /dev/null +++ b/flask/kontor/library/views.py @@ -0,0 +1,228 @@ +"""Define routing rules for book publishers""" +from flask import Blueprint, flash, redirect, render_template, url_for +from flask_login import login_required +from bson import ObjectId +from pymongo.errors import PyMongoError +from .forms import PublisherForm, AuthorForm, BookForm +from .models import Publisher, Author, Book + + +LIBRARY = Blueprint('library', __name__) + + +@LIBRARY.route('/publishers') +@login_required +def list_publishers(): + """ + List all publishers + :return: + """ + publishers = Publisher.objects.all() + return render_template('library/publishers.html', + publishers=publishers, title="Publishers") + + +@LIBRARY.route('/publishers/add', methods=['GET', 'POST']) +@login_required +def add_publisher(): + """ + Add a publisher to the database + :return: + """ + form = PublisherForm() + if form.validate_on_submit(): + publisher = Publisher() + publisher.name = form.name.data + try: + publisher.save() + flash('You have successfully added a new publisher.') + except PyMongoError: + flash('Error: publisher name already exists.') + return redirect(url_for('library.list_publishers')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Publisher") + + +@LIBRARY.route('/publishers/edit/', methods=['GET', 'POST']) +@login_required +def edit_publisher(publisher_id): + """ + Edit a publisher + """ + publisher = Publisher.objects.get({'_id': ObjectId(publisher_id)}) + form = PublisherForm(obj=publisher) + if form.validate_on_submit(): + publisher.name = form.name.data + publisher.save() + flash('You have successfully edited the publisher.') + return redirect(url_for('library.list_publishers')) + form.name.data = publisher.name + return render_template('simpleform.html', action="Edit", + form=form, publisher=publisher, title="Edit Publisher") + + +@LIBRARY.route('/publishers/delete/', methods=['GET', 'POST']) +@login_required +def delete_publisher(publisher_id): + """ + Delete a publisher + :param publisher_id: ObjectId of publisher + :return: + """ + publisher = Publisher.objects.raw({'_id': ObjectId(publisher_id)}) + if publisher: + publisher.delete() + flash('You have successfully deleted the publisher.') + return redirect(url_for('library.list_publishers')) + + +@LIBRARY.route('/authors') +@login_required +def list_authors(): + """ + List all authors + :return: + """ + authors = Author.objects.all() + return render_template('library/authors.html', + authors=authors, title="Authors") + + +@LIBRARY.route('/authors/edit/', methods=['GET', 'POST']) +@login_required +def edit_author(author_id): + """ + Edit a book artist + """ + author = Author.objects.get({'_id': ObjectId(author_id)}) + form = AuthorForm(obj=author) + if form.validate_on_submit(): + author.name = form.name.data + author.save() + flash('You have successfully edited the author.') + return redirect(url_for('library.list_authors')) + form.name.data = author.name + return render_template('simpleform.html', action="Edit", + form=form, author=author, title="Edit Author") + + +@LIBRARY.route('/authors/add', methods=['GET', 'POST']) +@login_required +def add_author(): + """ + Add a author + :return: + """ + form = AuthorForm() + if form.validate_on_submit(): + author = Author() + author.name = form.name.data + try: + author.save() + flash('You have successfully added a new author.') + except PyMongoError: + flash('Error: author name already exists.') + return redirect(url_for('library.list_authors')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Author") + + +@LIBRARY.route('/authors/delete/', methods=['GET', 'POST']) +@login_required +def delete_author(author_id): + """ + Delete a author + :param author_id: + :return: + """ + author = Author.objects.raw({'_id': ObjectId(author_id)}) + if author: + author.delete() + flash('You have successfully deleted the author.') + return redirect(url_for('library.list_authors')) + + +@LIBRARY.route('/books') +@login_required +def list_books(): + """ + List all comics + :return: + """ + books = Book.objects.all() + return render_template('library/books.html', + books=books, title="Books") + + +@LIBRARY.route('/books/edit/', methods=['GET', 'POST']) +@login_required +def edit_book(book_id): + """ + Edit a book + """ + book = Book.objects.get({'_id': ObjectId(book_id)}) + form = BookForm(obj=book) + form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] + if book.publisher: + form.publisher.default = book.publisher.pk + form.publisher.process_data(book.publisher.pk) + form.author.choices = [(a.pk, a.name) for a in Author.objects.all()] + if book.author: + form.author.default = book.author.pk + form.author.process_data(book.author.pk) + if form.validate_on_submit(): + book.title = form.title.data + book.publisher = form.publisher.data + book.author = form.author.data + if form.isbn.data: + book.isbn = form.isbn.data + else: + book.isbn = None + book.year = form.year.data + book.save() + flash('You have successfully edited the book.') + return redirect(url_for('library.list_books')) + form.title.data = book.title + return render_template('simpleform.html', action="Edit", + form=form, book=book, title="Edit Book") + + +@LIBRARY.route('/books/add', methods=['GET', 'POST']) +@login_required +def add_book(): + """ + Add a book + :return: + """ + form = BookForm() + form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] + form.author.choices = [(a.pk, a.name) for a in Author.objects.all()] + if form.validate_on_submit(): + book = Book() + book.title = form.title.data + book.publisher = form.publisher.data + book.author = form.author.data + book.year = form.year.data + try: + book.save() + flash('You have successfully added a new book.') + except PyMongoError: + flash('Error: book title already exists.') + return redirect(url_for('library.list_books')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Book") + + +@LIBRARY.route('/books/delete/', methods=['GET', 'POST']) +@login_required +def delete_book(book_id): + """ + Delete a book + :param book_id: + :return: + """ + book = Book.objects.raw({'_id': ObjectId(book_id)}) + if book: + book.delete() + flash('You have successfully deleted the book.') + return redirect(url_for('library.list_books')) diff --git a/flask/kontor/models.py b/flask/kontor/models.py new file mode 100644 index 0000000..7150364 --- /dev/null +++ b/flask/kontor/models.py @@ -0,0 +1,66 @@ +""" +This modules declares the model for Kontor users. +""" +from pymodm import MongoModel, fields +from pymongo.write_concern import WriteConcern +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from kontor import _LOGIN_MANAGER_ + + +class User(UserMixin, MongoModel): + """ + This class represents an user for the Kontor application. + """ + email = fields.EmailField() + username = fields.CharField(max_length=60) + first_name = fields.CharField(max_length=60) + last_name = fields.CharField(max_length=60) + password_hash = fields.CharField(max_length=128) + is_admin = fields.BooleanField(default=False) + + @property + def password(self): + """ + Prevent pasword from being accessed + """ + raise AttributeError('password is not a readable attribute.') + + @password.setter + def password(self, password): + """ + Set password to a hashed password + """ + self.password_hash = generate_password_hash(password) + + def verify_password(self, password): + """ + Check if hashed password matches actual password + """ + return check_password_hash(self.password_hash, password) + + def get_id(self): + """ + Get username as id + :return: + """ + return self.username + + def __repr__(self): + return ''.format(self.username) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +# Set up user_loader +@_LOGIN_MANAGER_.user_loader +def load_user(user_name): + """ + Get list of users from database + :param user_name: + :return: + """ + return User.objects.get({'username': user_name}) diff --git a/flask/kontor/office/__init__.py b/flask/kontor/office/__init__.py new file mode 100644 index 0000000..d830d41 --- /dev/null +++ b/flask/kontor/office/__init__.py @@ -0,0 +1,3 @@ +""" +Define routing rules for office related information +""" diff --git a/flask/kontor/static/css/style.css b/flask/kontor/static/css/style.css new file mode 100644 index 0000000..35fe053 --- /dev/null +++ b/flask/kontor/static/css/style.css @@ -0,0 +1,126 @@ +/* app/static/css/style.css */ + +body, html { + width: 100%; + height: 100%; +} + +body, h1, h2, h3 { + font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 700; +} + +a, .navbar-default .navbar-brand, .navbar-default .navbar-nav>li>a { + color: #aec251; +} + +a:hover, .navbar-default .navbar-brand:hover, .navbar-default .navbar-nav>li>a:hover { + color: #687430; +} + +footer { + padding: 50px 0; + background-color: #f8f8f8; +} + +p.copyright { + margin: 15px 0 0; +} + +.alert-info { + width: 50%; + margin: auto; + color: #687430; + background-color: #e6ecca; + border-color: #aec251; +} + +.btn-default { + border-color: #aec251; + color: #aec251; +} + +.btn-default:hover { + background-color: #aec251; +} + +.center { + margin: auto; + width: 70%; + padding: 10px; +} + +.content-section { + padding: 50px 0; + border-top: 1px solid #e7e7e7; +} + +.footer, .push { + clear: both; + height: 4em; +} + +.intro-divider { + width: 400px; + border-top: 1px solid #f8f8f8; + border-bottom: 1px solid rgba(0,0,0,0.2); +} + +.intro-header { + padding-top: 50px; + padding-bottom: 50px; + text-align: center; + color: #f8f8f8; + background: url(../img/intro-bg.jpg) no-repeat center center; + background-size: cover; + height: 100%; +} + +.intro-message { + position: relative; + padding-top: 20%; + padding-bottom: 20%; +} + +.intro-message > h1 { + margin: 0; + text-shadow: 2px 2px 3px rgba(0,0,0,0.6); + font-size: 5em; +} + +.intro-message > h3 { + text-shadow: 2px 2px 3px rgba(0,0,0,0.6); +} + +.lead { + font-size: 18px; + font-weight: 400; +} + +.topnav { + font-size: 14px; +} + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -4em; +} + +.outer { + display: table; + position: absolute; + height: 70%; + width: 100%; +} + +.middle { + display: table-cell; + vertical-align: middle; +} + +.inner { + margin-left: auto; + margin-right: auto; +} diff --git a/flask/kontor/templates/admin/users/user.html b/flask/kontor/templates/admin/users/user.html new file mode 100644 index 0000000..50df2c4 --- /dev/null +++ b/flask/kontor/templates/admin/users/user.html @@ -0,0 +1,21 @@ + + +{% import "bootstrap/wtf.html" as wtf %} +{% extends "base.html" %} +{% block title %}Edit User{% endblock %} +{% block body %} +
+
+
+
+
+

Edit User

+
+
+ {{ wtf.quick_form(form) }} +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/admin/users/users.html b/flask/kontor/templates/admin/users/users.html new file mode 100644 index 0000000..b23f67c --- /dev/null +++ b/flask/kontor/templates/admin/users/users.html @@ -0,0 +1,58 @@ + + +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Users{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Users

+ {% if users %} +
+
+ + + + + + + + + + + {% for user in users %} + {% if user.is_admin %} + + + + + + + {% else %} + + + + + + + {% endif %} + {% endfor %} + +
Name Email Username Edit
Admin N/A N/A N/A
{{ user.first_name }} {{ user.last_name }} {{ user.email }} {{ user.username }} + + Edit + +
+
+ {% endif %} +
+
+
+
+ +{% endblock %} diff --git a/flask/kontor/templates/auth/login.html b/flask/kontor/templates/auth/login.html new file mode 100644 index 0000000..8b70e01 --- /dev/null +++ b/flask/kontor/templates/auth/login.html @@ -0,0 +1,18 @@ + + +{% import "bootstrap/utils.html" as utils %} +{% import "bootstrap/wtf.html" as wtf %} +{% extends "base.html" %} +{% block title %}Login{% endblock %} +{% block body %} +
+
+ {{ utils.flashed_messages() }} +
+
+

Login to your account

+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} diff --git a/flask/kontor/templates/auth/register.html b/flask/kontor/templates/auth/register.html new file mode 100644 index 0000000..55d4918 --- /dev/null +++ b/flask/kontor/templates/auth/register.html @@ -0,0 +1,14 @@ + + +{% import "bootstrap/wtf.html" as wtf %} +{% extends "base.html" %} +{% block title %}Register{% endblock %} +{% block body %} +
+
+

Register for an account

+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} diff --git a/flask/kontor/templates/base.html b/flask/kontor/templates/base.html new file mode 100644 index 0000000..80bdc27 --- /dev/null +++ b/flask/kontor/templates/base.html @@ -0,0 +1,107 @@ + + + + + {{ title }} | Kontor + + + + + + + +
+
+ {% block body %} + {% endblock %} +
+
+
+
+
+

+

+ +
+
+ +
+ + diff --git a/flask/kontor/templates/comics/artists.html b/flask/kontor/templates/comics/artists.html new file mode 100644 index 0000000..a92415e --- /dev/null +++ b/flask/kontor/templates/comics/artists.html @@ -0,0 +1,58 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Comic Artists{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Comic Artists

+ {% if artists %} +
+
+ + + + + + + + + + {% for artist in artists %} + + + + + + {% endfor %} + +
Name Edit Delete
{{ artist.name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No artists have been added.

+
+ {% endif %} + + + Add Artist + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/comics/comics.html b/flask/kontor/templates/comics/comics.html new file mode 100644 index 0000000..c596ad5 --- /dev/null +++ b/flask/kontor/templates/comics/comics.html @@ -0,0 +1,66 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Comics{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Comics

+ {% if comics %} +
+
+ + + + + + + + + + + + {% for comic in comics %} + + + + + + + + {% endfor %} + +
Title Publisher Current Order Edit Delete
{{ comic.title }} + + {{ comic.publisher.name }} + + {{ comic.current_order }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No comics have been added.

+
+ {% endif %} + + + Add Comic + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/comics/publishers.html b/flask/kontor/templates/comics/publishers.html new file mode 100644 index 0000000..aa09f7b --- /dev/null +++ b/flask/kontor/templates/comics/publishers.html @@ -0,0 +1,58 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Comic Publishers{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Comic Publishers

+ {% if publishers %} +
+
+ + + + + + + + + + {% for publisher in publishers %} + + + + + + {% endfor %} + +
Name Edit Delete
{{ publisher.name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No comics have been added.

+
+ {% endif %} + + + Add Publisher + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/home/admin_dashboard.html b/flask/kontor/templates/home/admin_dashboard.html new file mode 100644 index 0000000..77a0c99 --- /dev/null +++ b/flask/kontor/templates/home/admin_dashboard.html @@ -0,0 +1,31 @@ + + +{% extends "base.html" %} +{% block title %}Admin Dashboard{% endblock %} +{% block body %} +
+
+
+
+
+

Admin Dashboard

+
+ +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/home/dashboard.html b/flask/kontor/templates/home/dashboard.html new file mode 100644 index 0000000..6764375 --- /dev/null +++ b/flask/kontor/templates/home/dashboard.html @@ -0,0 +1,20 @@ + + +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block body %} +
+
+
+
+
+

The Dashboard

+

We made it here!

+
+ +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/home/index.html b/flask/kontor/templates/home/index.html new file mode 100644 index 0000000..5b01225 --- /dev/null +++ b/flask/kontor/templates/home/index.html @@ -0,0 +1,20 @@ + + +{% extends "base.html" %} +{% block title %}Home{% endblock %} +{% block body %} +
+
+
+
+
+

Project Kontor

+

Store everything!

+
+ +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/library/authors.html b/flask/kontor/templates/library/authors.html new file mode 100644 index 0000000..8c24f7b --- /dev/null +++ b/flask/kontor/templates/library/authors.html @@ -0,0 +1,58 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Library Authors{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Library Authors

+ {% if authors %} +
+
+ + + + + + + + + + {% for author in authors %} + + + + + + {% endfor %} + +
Name Edit Delete
{{ author.name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No artists have been added.

+
+ {% endif %} + + + Add Author + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/library/books.html b/flask/kontor/templates/library/books.html new file mode 100644 index 0000000..6a6ef76 --- /dev/null +++ b/flask/kontor/templates/library/books.html @@ -0,0 +1,74 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Books{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Books

+ {% if books %} +
+
+ + + + + + + + + + + + + + {% for book in books %} + + + + + + + + + + {% endfor %} + +
Title Author Publisher ISBN Year Edit Delete
{{ book.title }} + + {{ book.author.name }} + + + + {{ book.publisher.name }} + + {{ book.isbn }} {{ book.year }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No books have been added.

+
+ {% endif %} + + + Add Book + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/library/publishers.html b/flask/kontor/templates/library/publishers.html new file mode 100644 index 0000000..d0d363c --- /dev/null +++ b/flask/kontor/templates/library/publishers.html @@ -0,0 +1,58 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Book Publishers{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Book Publishers

+ {% if publishers %} +
+
+ + + + + + + + + + {% for publisher in publishers %} + + + + + + {% endfor %} + +
Name Edit Delete
{{ publisher.name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No publishers have been added.

+
+ {% endif %} + + + Add Publisher + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/simpleform.html b/flask/kontor/templates/simpleform.html new file mode 100644 index 0000000..6ce9d3b --- /dev/null +++ b/flask/kontor/templates/simpleform.html @@ -0,0 +1,21 @@ +{% import "bootstrap/wtf.html" as wtf %} +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block body %} +
+
+
+
+
+

{{ title }}

+
+
+ {{ wtf.quick_form(form) }} +
+
+
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/tysc/manufacturers.html b/flask/kontor/templates/tysc/manufacturers.html new file mode 100644 index 0000000..f71ed75 --- /dev/null +++ b/flask/kontor/templates/tysc/manufacturers.html @@ -0,0 +1,58 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

{{ title }}

+ {% if manufacturers %} +
+
+ + + + + + + + + + {% for manufacturer in manufacturers %} + + + + + + {% endfor %} + +
Name Edit Delete
{{ manufacturer.name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No manufacturers have been added.

+
+ {% endif %} + + + Add Manufacturer + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/tysc/players.html b/flask/kontor/templates/tysc/players.html new file mode 100644 index 0000000..db25067 --- /dev/null +++ b/flask/kontor/templates/tysc/players.html @@ -0,0 +1,60 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

{{ title }}

+ {% if players %} +
+
+ + + + + + + + + + + {% for player in players %} + + + + + + + {% endfor %} + +
First Name Last Name Edit Delete
{{ player.first_name }} {{ player.last_name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No players have been added.

+
+ {% endif %} + + + Add Player + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/tysc/positions.html b/flask/kontor/templates/tysc/positions.html new file mode 100644 index 0000000..3b49e6e --- /dev/null +++ b/flask/kontor/templates/tysc/positions.html @@ -0,0 +1,62 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

{{ title }}

+ {% if positions %} +
+
+ + + + + + + + + + + + {% for position in positions %} + + + + + + + + {% endfor %} + +
Name Description Sport Edit Delete
{{ position.name }} {{ position.description }} {{ position.sport.name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No positions have been added.

+
+ {% endif %} + + + Add Position + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/tysc/sports.html b/flask/kontor/templates/tysc/sports.html new file mode 100644 index 0000000..1efe09a --- /dev/null +++ b/flask/kontor/templates/tysc/sports.html @@ -0,0 +1,58 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Sports{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Sports

+ {% if sports %} +
+
+ + + + + + + + + + {% for sport in sports %} + + + + + + {% endfor %} + +
Name Edit Delete
{{ sport.name }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No sports have been added.

+
+ {% endif %} + + + Add Sport + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/templates/tysc/teams.html b/flask/kontor/templates/tysc/teams.html new file mode 100644 index 0000000..c51e9bf --- /dev/null +++ b/flask/kontor/templates/tysc/teams.html @@ -0,0 +1,60 @@ +{% import "bootstrap/utils.html" as utils %} +{% extends "base.html" %} +{% block title %}Teams{% endblock %} +{% block body %} +
+
+
+
+
+ {{ utils.flashed_messages() }} +
+

Teams

+ {% if teams %} +
+
+ + + + + + + + + + + {% for team in teams %} + + + + + + + {% endfor %} + +
Name Shortname Edit Delete
{{ team.name }} {{ team.shortname }} + + Edit + + + + Delete + +
+
+
+ {% else %} +
+

No teams have been added.

+
+ {% endif %} + + + Add Team + +
+
+
+
+
+{% endblock %} diff --git a/flask/kontor/tysc/__init__.py b/flask/kontor/tysc/__init__.py new file mode 100644 index 0000000..9f06f83 --- /dev/null +++ b/flask/kontor/tysc/__init__.py @@ -0,0 +1,377 @@ +""" +Define routing rules for TradeYourSportsCards related information +""" +from .models import Sport, Team, Position, Player +from .models import Manufacturer, CardSet, ParallelSet, InsertSet, Card + + +def initialize_model(): + """ + Initialize collections for module TradeYourSportsCards. + """ + initialize_sport() + initialize_position() + initialize_teams() + initialize_players() + initialize_manufacturers() + initialize_card_sets() + initialize_parallel_sets() + initialize_insert_sets() + initialize_cards() + + +def initialize_sport(): + """ + Initialize collection Sport with american sports. + """ + for sport in Sport.objects.all(): + sport.delete() + sports = ["Baseball", "Basketball", "Football", "Hockey"] + for sport_name in sports: + sport = Sport() + sport.name = sport_name + sport.save() + + +def initialize_position(): + """ + Initialize collection Position with data. + """ + for position in Position.objects.all(): + position.delete() + positions = { + 'Quarterback': {'short': 'QB', 'sport': 'Football'}, + 'Wide Receiver': {'short': 'WR', 'sport': 'Football'}, + 'Runningback': {'short': 'RB', 'sport': 'Football'}, + 'Linebacker': {'short': 'LB', 'sport': 'Football'}, + 'Tight End': {'short': 'TE', 'sport': 'Football'}, + 'Fullback': {'short': 'FB', 'sport': 'Football'}, + 'Strong Safety': {'short': 'SS', 'sport': 'Football'}, + 'Defensive End': {'short': 'DE', 'sport': 'Football'}, + 'Kicker': {'short': 'K', 'sport': 'Football'}, + 'Punter': {'short': 'P', 'sport': 'Football'}, + 'Left Guard': {'short': 'LG', 'sport': 'Football'}, + 'Right Guard': {'short': 'RG', 'sport': 'Football'}, + 'Offensive Tackle': {'short': 'OT', 'sport': 'Football'}, + 'Defensive Back': {'short': 'DB', 'sport': 'Football'}, + 'Cornerback': {'short': 'CB', 'sport': 'Football'}, + 'Catcher': {'short': 'C', 'sport': 'Baseball'}, + 'First Base': {'short': '1B', 'sport': 'Baseball'}, + 'Second Base': {'short': '2B', 'sport': 'Baseball'}, + 'Third Base': {'short': '3B', 'sport': 'Baseball'}, + 'Shortstop': {'short': 'SS', 'sport': 'Baseball'}, + 'Left Field': {'short': 'LF', 'sport': 'Baseball'}, + 'Center Field': {'short': 'CF', 'sport': 'Baseball'}, + 'Right Field': {'short': 'RF', 'sport': 'Baseball'}, + 'Designated Hitter': {'short': 'DH', 'sport': 'Baseball'}, + 'Pitcher': {'short': 'P', 'sport': 'Baseball'} + } + for key in positions: + sport = Sport.objects.get({'name': positions.get(key)['sport']}) + position = Position() + position.description = key + position.name = positions.get(key)['short'] + position.sport = sport + position.save() + + +def initialize_teams(): + """ + Initialize collection Team with data. + """ + for team in Team.objects.all(): + team.delete() + teams = {'Buffalo Bills': {'short': 'Bills', 'sport': 'Football'}, + 'Indianapolis Colts': {'short': 'Colts', 'sport': 'Football'}, + 'Miami Dolphins': {'short': 'Dolphins', 'sport': 'Football'}, + 'New England Patriots': {'short': 'Patriots', 'sport': 'Football'}, + 'New York Jets': {'short': 'Jets', 'sport': 'Football'}, + 'Baltimore Ravens': {'short': 'Ravens', 'sport': 'Football'}, + 'Cincinnati Bengals': {'short': 'Bengals', 'sport': 'Football'}, + 'Cleveland Browns': {'short': 'Browns', 'sport': 'Football'}, + 'Jacksonville Jaguars': {'short': 'Jaguars', 'sport': 'Football'}, + 'Pittsburgh Steelers': {'short': 'Steelers', 'sport': 'Football'}, + 'Tennessee Titans': {'short': 'Titans', 'sport': 'Football'}, + 'Denver Broncos': {'short': 'Broncos', 'sport': 'Football'}, + 'Kansas City Chiefs': {'short': 'Chiefs', 'sport': 'Football'}, + 'Oakland Raiders': {'short': 'Raiders', 'sport': 'Football'}, + 'San Diego Chargers': {'short': 'Chargers', 'sport': 'Football'}, + 'Seattle Seahawks': {'short': 'Seahawks', 'sport': 'Football'}, + 'Arizona Cardinals': {'short': 'Cardinals', 'sport': 'Football'}, + 'Dallas Cowboys': {'short': 'Cowboys', 'sport': 'Football'}, + 'New York Giants': {'short': 'Giants', 'sport': 'Football'}, + 'Philadelphia Eagles': {'short': 'Eagles', 'sport': 'Football'}, + 'Washington Redskins': {'short': 'Redskins', 'sport': 'Football'}, + 'Chicago Bears': {'short': 'Bears', 'sport': 'Football'}, + 'Detroit Lions': {'short': 'Lions', 'sport': 'Football'}, + 'Green Bay Packers': {'short': 'Packers', 'sport': 'Football'}, + 'Minnesota Vikings': {'short': 'Vikings', 'sport': 'Football'}, + 'Tampa Bay Buccaneers': {'short': 'Buccaneers', 'sport': 'Football'}, + 'Atlanta Falcons': {'short': 'Falcons', 'sport': 'Football'}, + 'Carolina Panthers': {'short': 'Panthers', 'sport': 'Football'}, + 'New Orleans Saints': {'short': 'Saints', 'sport': 'Football'}, + 'St.Louis Rams': {'short': 'Rams', 'sport': 'Football'}, + 'San Francisco 49ers': {'short': '49ers', 'sport': 'Football'}, + 'Baltimore Orioles': {'short': 'Orioles', 'sport': 'Baseball'}, + 'Boston Red Sox': {'short': 'Red Sox', 'sport': 'Baseball'}, + 'New York Yankees': {'short': 'Yankees', 'sport': 'Baseball'}, + 'Tampa Bay Devil Rays': {'short': 'Devil Rays', 'sport': 'Baseball'}, + 'Toronto Blue Jays': {'short': 'Blue Jays', 'sport': 'Baseball'}, + 'Chicago White Sox': {'short': 'White Sox', 'sport': 'Baseball'}, + 'Cleveland Indians': {'short': 'Indians', 'sport': 'Baseball'}, + 'Detroit Tigers': {'short': 'Tigers', 'sport': 'Baseball'}, + 'Kansas City Royals': {'short': 'Royals', 'sport': 'Baseball'}, + 'Minnesota Twins': {'short': 'Twins', 'sport': 'Baseball'}, + 'Anaheim Angels': {'short': 'Angels', 'sport': 'Baseball'}, + 'Oakland Athletics': {'short': 'Athletics', 'sport': 'Baseball'}, + 'Seattle Mariners': {'short': 'Mariners', 'sport': 'Baseball'}, + 'Texas Rangers': {'short': 'Rangers', 'sport': 'Baseball'}, + 'Atlanta Braves': {'short': 'Braves', 'sport': 'Baseball'}, + 'Florida Marlins': {'short': 'Marlins', 'sport': 'Baseball'}, + 'Montreal Expos': {'short': 'Expos', 'sport': 'Baseball'}, + 'New York Mets': {'short': 'Mets', 'sport': 'Baseball'}, + 'Philadelphia Phillies': {'short': 'Phillies', 'sport': 'Baseball'}, + 'Chicago Cubs': {'short': 'Cubs', 'sport': 'Baseball'}, + 'Cincinnati Reds': {'short': 'Reds', 'sport': 'Baseball'}, + 'Houston Astros': {'short': 'Astros', 'sport': 'Baseball'}, + 'Milwaukee Brewers': {'short': 'Brewers', 'sport': 'Baseball'}, + 'Pittsburgh Pirates': {'short': 'Pirates', 'sport': 'Baseball'}, + 'St.Louis Cardinals': {'short': 'Cardinals', 'sport': 'Baseball'}, + 'Arizona Diamondbacks': {'short': 'Diamondbacks', 'sport': 'Baseball'}, + 'Colorado Rockies': {'short': 'Rockies', 'sport': 'Baseball'}, + 'Los Angeles Dodgers': {'short': 'Dodgers', 'sport': 'Baseball'}, + 'San Diego Padres': {'short': 'Padres', 'sport': 'Baseball'}, + 'San Francisco Giants': {'short': 'Giants', 'sport': 'Baseball'}, + 'Boston Celtics': {'short': 'Celtics', 'sport': 'Basketball'}, + 'Miami Heat': {'short': 'Heat', 'sport': 'Basketball'}, + 'New Jersey Nets': {'short': 'Mets', 'sport': 'Basketball'}, + 'New York Knicks': {'short': 'Knicks', 'sport': 'Basketball'}, + 'Orlando Magic': {'short': 'Magic', 'sport': 'Basketball'}, + 'Philadelphia 76ers': {'short': '76ers', 'sport': 'Basketball'}, + 'Washington Wizards': {'short': 'Wizards', 'sport': 'Basketball'}, + 'Atlanta Hawks': {'short': 'Hawks', 'sport': 'Basketball'}, + 'Charlotte Hornets': {'short': 'Hornets', 'sport': 'Basketball'}, + 'Chicago Bulls': {'short': 'Bulls', 'sport': 'Basketball'}, + 'Cleveland Cavaliers': {'short': 'Cavaliers', 'sport': 'Basketball'}, + 'Detroit Pistons': {'short': 'Pistons', 'sport': 'Basketball'}, + 'Indiana Pacers': {'short': 'Pacers', 'sport': 'Basketball'}, + 'Milwaukee Bucks': {'short': 'Bucks', 'sport': 'Basketball'}, + 'Toronto Raptors': {'short': 'Raptors', 'sport': 'Basketball'}, + 'Dallas Mavericks': {'short': 'Mavericks', 'sport': 'Basketball'}, + 'Denver Nuggets': {'short': 'Nuggets', 'sport': 'Basketball'}, + 'Houston Rockets': {'short': 'Rockets', 'sport': 'Basketball'}, + 'Minnesota Timberwolves': {'short': 'Timberwolves', 'sport': 'Basketball'}, + 'San Antonio Spurs': {'short': 'Spurs', 'sport': 'Basketball'}, + 'Utah Jazz': {'short': 'Jazz', 'sport': 'Basketball'}, + 'Vancouver Grizzlies': {'short': 'Grizzlies', 'sport': 'Basketball'}, + 'Golden State Warriors': {'short': 'Warriors', 'sport': 'Basketball'}, + 'Los Angeles Clippers': {'short': 'Clippers', 'sport': 'Basketball'}, + 'Los Angeles Lakers': {'short': 'Lakers', 'sport': 'Basketball'}, + 'Phoenix Suns': {'short': 'Suns', 'sport': 'Basketball'}, + 'Portland Trail Blazers': {'short': 'Blazers', 'sport': 'Basketball'}, + 'Sacramento Kings': {'short': 'Kings', 'sport': 'Basketball'}, + 'Seattle SuperSonics': {'short': 'SuperSonics', 'sport': 'Basketball'}, + 'Boston Bruins': {'short': 'Bruins', 'sport': 'Hockey'}, + 'Buffalo Sabres': {'short': 'Sabres', 'sport': 'Hockey'}, + 'Montreal Canadiens': {'short': 'Canadiens', 'sport': 'Hockey'}, + 'Ottawa Senators': {'short': 'Senators', 'sport': 'Hockey'}, + 'Toronto Maple Leafs': {'short': 'Maple Leafs', 'sport': 'Hockey'}, + 'New Jersey Devils': {'short': 'Devils', 'sport': 'Hockey'}, + 'New York Islander': {'short': 'Islander', 'sport': 'Hockey'}, + 'New York Rangers': {'short': 'Rangers', 'sport': 'Hockey'}, + 'Philadelphia Flyers': {'short': 'Flyers', 'sport': 'Hockey'}, + 'Pittsburgh Penguins': {'short': 'Penguins', 'sport': 'Hockey'}, + 'Atlanta Trashers': {'short': 'Trashers', 'sport': 'Hockey'}, + 'Carolina Hurricanes': {'short': 'Hurricanes', 'sport': 'Hockey'}, + 'Florida Panthers': {'short': 'Panthers', 'sport': 'Hockey'}, + 'Tampa Bay Lightnings': {'short': 'Lightnings', 'sport': 'Hockey'}, + 'Washington Capitals': {'short': 'Capitals', 'sport': 'Hockey'}, + 'Chicago Blackhawks': {'short': 'Blackhawks', 'sport': 'Hockey'}, + 'Columbo Blue Jackets': {'short': 'Blue Jackets', 'sport': 'Hockey'}, + 'Detroit Red Wings': {'short': 'Red Wings', 'sport': 'Hockey'}, + 'Nashville Predators': {'short': 'Predators', 'sport': 'Hockey'}, + 'St.Louis Blues': {'short': 'Blues', 'sport': 'Hockey'}, + 'Calgary Flames': {'short': 'Flames', 'sport': 'Hockey'}, + 'Colorado Avalanche': {'short': 'Avalanche', 'sport': 'Hockey'}, + 'Edmonton Oilers': {'short': 'Oilers', 'sport': 'Hockey'}, + 'Minnesota Wild': {'short': 'Wild', 'sport': 'Hockey'}, + 'Vancouver Canucks': {'short': 'Canucks', 'sport': 'Hockey'}, + 'Anaheim Mighty Ducks': {'short': 'Mighty Ducks', 'sport': 'Hockey'}, + 'Dallas Stars': {'short': 'Stars', 'sport': 'Hockey'}, + 'Los Angeles Kings': {'short': 'Kings', 'sport': 'Hockey'}, + 'Phoenix Coyotes': {'short': 'Coyotes', 'sport': 'Hockey'}, + 'San Jose Sharks': {'short': 'Sharks', 'sport': 'Hockey'}, + 'Houston Texans': {'short': 'Texans', 'sport': 'Football'}, + 'Houston Oilers': {'short': 'Oilers', 'sport': 'Football'} + } + for key in teams: + sport = Sport.objects.get({'name': teams.get(key)['sport']}) + team = Team() + team.name = key + team.shortname = teams.get(key)['short'] + team.sport = sport + team.save() + + +def initialize_players(): + """ + Initialize collection Manufacturer with data. + """ + for player in Player.objects.all(): + player.delete() + players = ['Pathon, Jerome', 'Bruschi, Tedy', 'Couch, Tim', 'Shea, Aaron', + 'Lewis, Jamal', 'Lewis, Jermaine', 'Banks, Tony', 'Fuamatu-Ma\'Afala, Chris', + 'Bettis, Jerome', 'Stewart, Kordell', 'Moon, Warren', 'Lockett, Kevin', + 'Gannon, Rich', 'Jett, James', 'Strong, Mack', 'Huard, Brock', + 'Watters, Ricky', 'Aikman, Troy', 'LaFleur, David', 'Brazzell, Chris', + 'Dayne, Ron', 'Brown, Na', 'Small, Torrance', 'Lewis, Chad', 'Murrell, Adrian', + 'Smith, Maurice', 'Chandler, Chris', 'Kanell, Danny', 'Williams, Ricky', + 'Garcia, Jeff', 'Streets, Tai', 'Garner, Charlie', 'Rice, Jerry', + 'Owens, Terrell', 'Bruce, Isaac', 'Canidate, Trung'] + for player_name in players: + player = Player() + (last_name, first_name) = player_name.split(',') + player.last_name = last_name.strip() + player.first_name = first_name.strip() + player.save() + + +def initialize_manufacturers(): + """ + Initialize collection Manufacturer with data. + """ + for manufacturer in Manufacturer.objects.all(): + manufacturer.delete() + manufacturers = ['Pacific', 'Fleer', 'Bowman', 'Topps', 'Donruss', + 'Score', 'Flair', 'Upper Deck'] + for manufacturer_name in manufacturers: + manufacturer = Manufacturer() + manufacturer.name = manufacturer_name + manufacturer.save() + + +def initialize_card_sets(): + """ + Initialize collection CardSet with data. + """ + for card_set in CardSet.objects.all(): + card_set.delete() + card_sets = {'Pacific': 'Pacific', + 'Fleer': 'Fleer', + 'Bowman': 'Bowman', + 'Leaf': 'Topps', + 'Ultra': 'Fleer', + 'Mystique': 'Fleer', + 'Finest Hour': 'Pacific', + 'SP': 'Upper Deck', + 'SPx': 'Upper Deck', + 'SP Authentic': 'Upper Deck', + 'Black Diamond': 'Upper Deck' + } + for set_name in card_sets: + card_set = CardSet() + card_set.name = set_name + card_set.manufacturer = Manufacturer.objects.get({'name': card_sets.get(set_name)}) + card_set.save() + + +def initialize_parallel_sets(): + """ + Initialize collection ParallelSet with data. + """ + for parallel_set in ParallelSet.objects.all(): + parallel_set.delete() + parallel_sets = {'Mystique Gold': 'Fleer', + 'Pacific Copper': 'Pacific', + 'Pacific Gold': 'Pacific' + } + for key in parallel_sets: + manufacturer = Manufacturer.objects.get({'name': parallel_sets.get(key)}) + parallel_set = ParallelSet() + parallel_set.name = key + parallel_set.manufacturer = manufacturer + parallel_set.save() + + +def initialize_insert_sets(): + """ + Initialize collection InsertSet with data. + """ + for insert_set in InsertSet.objects.all(): + insert_set.delete() + manufacturer = Manufacturer.objects.get({'name': 'Fleer'}) + insert_set = InsertSet() + insert_set.name = 'Mystique Big Buzz' + insert_set.manufacturer = manufacturer + insert_set.save() + + +def initialize_cards(): + """ + Initialize collection Card with data. + """ + for card in Card.objects.all(): + card.delete() + players = ['Pathon, Jerome', 'Bruschi, Tedy', 'Couch, Tim', 'Shea, Aaron', + 'Lewis, Jamal', 'Lewis, Jermaine', 'Banks, Tony', 'Fuamatu-Ma\'Afala, Chris', + 'Bettis, Jerome', 'Stewart, Kordell', 'Moon, Warren', 'Lockett, Kevin', + 'Gannon, Rich', 'Jett, James', 'Strong, Mack', 'Huard, Brock', + 'Watters, Ricky', 'Aikman, Troy', 'LaFleur, David', 'Brazzell, Chris', + 'Dayne, Ron', 'Brown, Na', 'Small, Torrance', 'Lewis, Chad', 'Murrell, Adrian', + 'Smith, Maurice', 'Chandler, Chris', 'Kanell, Danny', 'Williams, Ricky', + 'Garcia, Jeff', 'Streets, Tai', 'Garner, Charlie', 'Rice, Jerry', + 'Owens, Terrell', 'Bruce, Isaac', 'Canidate, Trung'] + cards = [ + [0, 'Indianapolis Colts', 'Pacific', 'Pacific', None, None, False, 2001, 185], + [1, 'Indianapolis Colts', 'Pacific', 'Pacific', None, None, False, 2001, 250], + [2, 'Cleveland Browns', 'Pacific', 'Pacific', None, None, False, 2001, 103], + [3, 'Cleveland Browns', 'Pacific', 'Pacific', None, None, False, 2001, 112], + [4, 'Baltimore Ravens', 'Pacific', 'Pacific', None, None, False, 2001, 37], + [5, 'Baltimore Ravens', 'Pacific', 'Pacific', None, None, False, 2001, 38], + [6, 'Baltimore Ravens', 'Pacific', 'Pacific', None, None, False, 2001, 31], + [7, 'Pittsburgh Steelers', 'Pacific', 'Pacific', None, None, False, 2001, 338], + [8, 'Pittsburgh Steelers', 'Pacific', 'Pacific', None, None, False, 2001, 335], + [9, 'Pittsburgh Steelers', 'Pacific', 'Pacific', None, None, False, 2001, 345], + [10, 'Kansas City Chiefs', 'Pacific', 'Pacific', None, None, False, 2001, 213], + [11, 'Kansas City Chiefs', 'Pacific', 'Pacific', None, None, False, 2001, 212], + [12, 'Oakland Raiders', 'Pacific', 'Pacific', None, None, False, 2001, 311], + [13, 'Oakland Raiders', 'Pacific', 'Pacific', None, None, False, 2001, 312], + [14, 'Seattle Seahawks', 'Pacific', 'Pacific', None, None, False, 2001, 403], + [15, 'Seattle Seahawks', 'Pacific', 'Pacific', None, None, False, 2001, 397], + [16, 'Seattle Seahawks', 'Pacific', 'Pacific', None, None, False, 2001, 404], + [17, 'Dallas Cowboys', 'Pacific', 'Pacific', None, None, False, 2001, 116], + [18, 'Dallas Cowboys', 'Pacific', 'Pacific', None, None, False, 2001, 122], + [19, 'Dallas Cowboys', 'Pacific', 'Pacific', None, None, False, 2001, 117], + [20, 'New York Giants', 'Pacific', 'Pacific', None, None, False, 2001, 281], + [21, 'New York Giants', 'Pacific', 'Pacific', None, None, False, 2001, 321], + [22, 'Philadelphia Eagles', 'Pacific', 'Pacific', None, None, False, 2001, 331], + [23, 'Philadelphia Eagles', 'Pacific', 'Pacific', None, None, False, 2001, 324], + [24, 'Washington Redskins', 'Pacific', 'Pacific', None, None, False, 2001, 445], + [25, 'Atlanta Falcons', 'Pacific', 'Pacific', None, None, False, 2001, 28], + [26, 'Atlanta Falcons', 'Pacific', 'Pacific', None, None, False, 2001, 17], + [27, 'Atlanta Falcons', 'Pacific', 'Pacific', None, None, False, 2001, 23], + [28, 'New Orleans Saints', 'Pacific', 'Pacific', None, None, False, 2001, 273], + [29, 'San Francisco 49ers', 'Pacific', 'Pacific', None, None, False, 2001, 380], + [30, 'San Francisco 49ers', 'Pacific', 'Pacific', None, None, False, 2001, 390], + [31, 'San Francisco 49ers', 'Pacific', 'Pacific', None, None, False, 2001, 381], + [32, 'San Francisco 49ers', 'Pacific', 'Pacific', None, None, False, 2001, 387], + [33, 'San Francisco 49ers', 'Pacific', 'Pacific', None, None, False, 2001, 386], + [34, 'St.Louis Rams', 'Pacific', 'Pacific', None, None, False, 2001, 349], + [35, 'St.Louis Rams', 'Pacific', 'Pacific', None, None, False, 2001, 350], + ] + for card_data in cards: + card = Card() + player_name = players[card_data[0]] + (last_name, first_name) = player_name.split(',') + card.player = Player.objects.get( + {'last_name': last_name.strip(), 'first_name': first_name.strip()} + ) + card.team = Team.objects.get({'name': card_data[1]}) + card.manufacturer = Manufacturer.objects.get({'name': card_data[2]}) + card.card_set = CardSet.objects.get({'name': card_data[3]}) + card.parallel_set = card_data[4] + card.insert_set = card_data[5] + card.rookie = card_data[6] + card.year = card_data[7] + card.number = card_data[8] + card.save() diff --git a/flask/kontor/tysc/forms.py b/flask/kontor/tysc/forms.py new file mode 100644 index 0000000..9cb5c3c --- /dev/null +++ b/flask/kontor/tysc/forms.py @@ -0,0 +1,76 @@ +""" +Define form to edit sport, teams, player, manufacturers and card types +""" +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, SelectField, IntegerField, BooleanField +from wtforms.validators import DataRequired, Optional, NumberRange +from bson import ObjectId + + +class SportForm(FlaskForm): + """ + Form to add and edit a Sport + """ + name = StringField('Name', validators=[DataRequired()]) + submit = SubmitField('Submit') + + +class TeamForm(FlaskForm): + """ + Form to add and edit a team + """ + name = StringField('Name', validators=[DataRequired()]) + shortname = StringField('Shortname', validators=[DataRequired()]) + sport = SelectField('Sport', coerce=ObjectId) + submit = SubmitField('Submit') + + +class PlayerForm(FlaskForm): + """ + Form to add and edit a player. + """ + first_name = StringField('First name', validators=[DataRequired()]) + last_name = StringField('Last name', validators=[DataRequired()]) + submit = SubmitField('Submit') + + +class PositionForm(FlaskForm): + """ + Form to add and edit field positions for sports + """ + name = StringField('Name', validators=[DataRequired()]) + description = StringField('Description', validators=[DataRequired()]) + sport = SelectField('Sport', coerce=ObjectId) + submit = SubmitField('Submit') + + +class ManufacturerForm(FlaskForm): + """ + Form to add and edit a card manufacturer. + """ + name = StringField('Name', validators=[DataRequired()]) + submit = SubmitField('Submit') + + +class CardSetForm(FlaskForm): + """ + Form to add and edit a regular card set + """ + name = StringField('Name', validators=[DataRequired()]) + manufacturer = SelectField('Manufacturer', coerce=ObjectId) + submit = SubmitField('Submit') + + +class CardForm(FlaskForm): + """ + Form to add and edit a trading card + """ + player = SelectField('Player', coerce=ObjectId) + team = SelectField('Team', coerce=ObjectId) + manufacturer = SelectField('Manufacturer', coerce=ObjectId) + card_set = SelectField('Card Set', coerce=ObjectId) + parallel_set = SelectField('Parallel Set', coerce=ObjectId, validators=[Optional()]) + insert_set = SelectField('Inserts', coerce=ObjectId, validators=[Optional()]) + rookie = BooleanField('Rookie', validators=[DataRequired()]) + year = IntegerField('Year', validators=[NumberRange(min=1956, max=2020)]) + number = IntegerField('Number', validators=[DataRequired()]) diff --git a/flask/kontor/tysc/models.py b/flask/kontor/tysc/models.py new file mode 100644 index 0000000..02dc341 --- /dev/null +++ b/flask/kontor/tysc/models.py @@ -0,0 +1,155 @@ +"""This modules declares the model for TradingCards related information.""" +from pymongo.write_concern import WriteConcern +from pymodm import MongoModel, fields + + +class Sport(MongoModel): + """Class Sport represents a sport.""" + name = fields.CharField() + + def __str__(self): + """Returns printable version of Sport object.""" + return self.name + + def __repr__(self): + """Returns printable version of Sport object.""" + return "Sport({})".format(self.name) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Position(MongoModel): + """Class Position represents the position of a player for a sport.""" + name = fields.CharField(max_length=4) + description = fields.CharField(max_length=30) + sport = fields.ReferenceField(Sport) + + def __str__(self): + """Returns printable version of Position object.""" + return "{0}({1})".format(self.name, self.description) + + def __repr__(self): + """Returns printable version of Position object.""" + return "Position({0}, {1})".format(self.name, self.description) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Team(MongoModel): + """Class Team represents a team for a sport.""" + name = fields.CharField(max_length=60) + shortname = fields.CharField(max_length=30) + sport = fields.ReferenceField(Sport, blank=True) + + def __str__(self): + """Returns printable version of Team object.""" + return self.name + + def __repr__(self): + """Returns printable version of Team object.""" + return "Team({0}{1})".format(self.name, self.shortname) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Player(MongoModel): + """ + Class Player represents a player. + """ + first_name = fields.CharField(max_length=60) + last_name = fields.CharField(max_length=60) + + def __str__(self): + """Returns printable version of Team object.""" + return "{0} {1}".format(self.first_name, self.last_name) + + def __repr__(self): + """Returns printable version of Team object.""" + return "Player({0} {1})".format(self.first_name, self.last_name) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Manufacturer(MongoModel): + """ + Class Manufacturer represents a manufacturer of trading cards. + """ + name = fields.CharField() + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class CardSet(MongoModel): + """ + Class CardSet represents the regular card set. + """ + name = fields.CharField() + manufacturer = fields.ReferenceField(Manufacturer) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class ParallelSet(MongoModel): + """ + Class CardSet represents the parallel card set. + """ + name = fields.CharField() + manufacturer = fields.ReferenceField(Manufacturer) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class InsertSet(MongoModel): + """ + Class CardSet represents the inserts card set. + """ + name = fields.CharField() + manufacturer = fields.ReferenceField(Manufacturer) + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) + + +class Card(MongoModel): + """ + Class CardSet represents the regular card set. + """ + # pylint: disable=too-many-instance-attributes + # Nine is reasonable in this case. + player = fields.ReferenceField(Player) + team = fields.ReferenceField(Team) + manufacturer = fields.ReferenceField(Manufacturer) + card_set = fields.ReferenceField(CardSet, blank=True) + parallel_set = fields.ReferenceField(ParallelSet, blank=True) + insert_set = fields.ReferenceField(InsertSet, blank=True) + rookie = fields.BooleanField(default=False) + year = fields.IntegerField(min_value=1956, max_value=2020) + number = fields.IntegerField() + + class Meta: + """Sets the connection and connections details.""" + connection_alias = 'kontor' + write_concern = WriteConcern(j=True) diff --git a/flask/kontor/tysc/views.py b/flask/kontor/tysc/views.py new file mode 100644 index 0000000..836b295 --- /dev/null +++ b/flask/kontor/tysc/views.py @@ -0,0 +1,405 @@ +""" +Define BLueprint and Views for TradeYourSportsCards +""" +from flask import Blueprint, url_for, render_template, redirect, flash +from flask_login import login_required +from bson import ObjectId +from pymongo.errors import PyMongoError +from .forms import SportForm, TeamForm, PlayerForm, PositionForm +from .forms import ManufacturerForm +from .models import Sport, Team, Player, Position +from .models import Manufacturer, CardSet, ParallelSet, InsertSet, Card + + +TYSC = Blueprint('tysc', __name__) + + +@TYSC.route('/sport') +@login_required +def list_sports(): + """ + List sports. + :return: + """ + sports = Sport.objects.all() + return render_template('tysc/sports.html', sports=sports, title="Sports") + + +@TYSC.route('/sport/edit/', methods=['GET', 'POST']) +@login_required +def edit_sport(sport_id): + """ + Edit a sport + """ + sport = Sport.objects.get({'_id': ObjectId(sport_id)}) + form = SportForm(obj=sport) + if form.validate_on_submit(): + sport.name = form.name.data + sport.save() + flash('You have successfully edited the sport.') + return redirect(url_for('tysc.list_sports')) + form.name.data = sport.name + return render_template('simpleform.html', action="Edit", + form=form, sport=sport, title="Edit Sport") + + +@TYSC.route('/sport/add', methods=['GET', 'POST']) +@login_required +def add_sport(): + """ + Add a sport + :return: + """ + form = SportForm() + if form.validate_on_submit(): + sport = Sport() + sport.name = form.name.data + try: + sport.save() + flash('You have successfully added a new sport.') + except PyMongoError: + flash('Error: sport name already exists.') + return redirect(url_for('tysc.list_sports')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Sport") + + +@TYSC.route('/sport/delete/', methods=['GET', 'POST']) +@login_required +def delete_sport(sport_id): + """ + Delete a sport + :param sport_id: + :return: + """ + sport = Sport.objects.raw({'_id': ObjectId(sport_id)}) + if sport: + sport.delete() + flash('You have successfully deleted the sport.') + return redirect(url_for('tysc.list_sports')) + + +@TYSC.route('/team') +@login_required +def list_teams(): + """ + List teams. + :return: + """ + teams = Team.objects.all() + return render_template('tysc/teams.html', teams=teams, title="Teams") + + +@TYSC.route('/team/edit/', methods=['GET', 'POST']) +@login_required +def edit_team(team_id): + """ + Edit a team + """ + team = Team.objects.get({'_id': ObjectId(team_id)}) + form = TeamForm(obj=team) + form.sport.choices = [(s.pk, s.name) for s in Sport.objects.all()] + if team.sport: + form.sport.default = team.sport.pk + form.sport.process_data(team.sport.pk) + if form.validate_on_submit(): + team.name = form.name.data + team.shortname = form.shortname.data + team.sport = form.sport.data + team.save() + flash('You have successfully edited the team.') + return redirect(url_for('tysc.list_teams')) + form.name.data = team.name + return render_template('simpleform.html', action="Edit", + form=form, team=team, title="Edit Team") + + +@TYSC.route('/team/add', methods=['GET', 'POST']) +@login_required +def add_team(): + """ + Add a team + :return: + """ + form = TeamForm() + form.sport.choices = [(s.pk, s.name) for s in Sport.objects.all()] + if form.validate_on_submit(): + team = Team() + team.name = form.name.data + team.shortname = form.shortname.data + team.sport = form.sport.data + try: + team.save() + flash('You have successfully added a new team.') + except PyMongoError: + flash('Error: team name already exists.') + return redirect(url_for('tysc.list_teams')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Team") + + +@TYSC.route('/team/delete/', methods=['GET', 'POST']) +@login_required +def delete_team(team_id): + """ + Delete a team + :param team_id: + :return: + """ + team = Team.objects.raw({'_id': ObjectId(team_id)}) + if team: + team.delete() + return redirect(url_for('tysc.list_teams')) + + +@TYSC.route('/position') +@login_required +def list_positions(): + """ + List positions. + :return: + """ + positions = Position.objects.all() + return render_template('tysc/positions.html', positions=positions, title="Positions") + + +@TYSC.route('/position/edit/', methods=['GET', 'POST']) +@login_required +def edit_position(position_id): + """ + Edit a position + """ + position = Position.objects.get({'_id': ObjectId(position_id)}) + form = PositionForm(obj=position) + form.sport.choices = [(s.pk, s.name) for s in Sport.objects.all()] + if position.sport: + form.sport.default = position.sport.pk + form.sport.process_data(position.sport.pk) + if form.validate_on_submit(): + position.name = form.name.data + position.description = form.description.data + position.sport = form.sport.data + position.save() + flash('You have successfully edited the position.') + return redirect(url_for('tysc.list_positions')) + form.name.data = position.name + form.description.data = position.description + return render_template('simpleform.html', action="Edit", + form=form, position=position, title="Edit position") + + +@TYSC.route('/position/add', methods=['GET', 'POST']) +@login_required +def add_position(): + """ + Add a position + :return: + """ + form = PositionForm() + form.sport.choices = [(s.pk, s.name) for s in Sport.objects.all()] + if form.validate_on_submit(): + position = Position() + position.name = form.name.data + position.description = form.description.data + position.sport = form.sport.data + try: + position.save() + flash('You have successfully added a new position.') + except PyMongoError: + flash('Error: team position already exists.') + return redirect(url_for('tysc.list_positions')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Position") + + +@TYSC.route('/position/delete/', methods=['GET', 'POST']) +@login_required +def delete_position(position_id): + """ + Delete a position + :param position_id: + :return: + """ + position = Position.objects.raw({'_id': ObjectId(position_id)}) + if position: + position.delete() + return redirect(url_for('tysc.list_positions')) + + +@TYSC.route('/player') +@login_required +def list_players(): + """ + List players. + :return: + """ + players = Player.objects.all() + return render_template('tysc/players.html', players=players, title="Players") + + +@TYSC.route('/player/edit/', methods=['GET', 'POST']) +@login_required +def edit_player(player_id): + """ + Edit a player + """ + player = Player.objects.get({'_id': ObjectId(player_id)}) + form = PlayerForm(obj=player) + if form.validate_on_submit(): + player.first_name = form.first_name.data + player.last_name = form.last_name.data + player.save() + flash('You have successfully edited the player.') + return redirect(url_for('tysc.list_players')) + form.first_name.data = player.first_name + form.last_name.data = player.last_name + return render_template('simpleform.html', action="Edit", + form=form, player=player, title="Edit player") + + +@TYSC.route('/player/add', methods=['GET', 'POST']) +@login_required +def add_player(): + """ + Add a player + :return: + """ + form = PlayerForm() + if form.validate_on_submit(): + player = Player() + player.first_name = form.first_name.data + player.last_name = form.last_name.data + try: + player.save() + flash('You have successfully added a new player.') + except PyMongoError: + flash('Error: player name already exists.') + return redirect(url_for('tysc.list_players')) + return render_template('simpleform.html', action="Add", + form=form, title="Add player") + + +@TYSC.route('/player/delete/', methods=['GET', 'POST']) +@login_required +def delete_player(player_id): + """ + Delete a player + :param player_id: + :return: + """ + player = Player.objects.raw({'_id': ObjectId(player_id)}) + if player: + player.delete() + return redirect(url_for('tysc.list_players')) + + +@TYSC.route('/manufacturer') +@login_required +def list_manufacturers(): + """ + List manufacturers. + :return: + """ + manufacturers = Manufacturer.objects.all() + return render_template('tysc/manufacturers.html', + manufacturers=manufacturers, + title="Manufacturers") + + +@TYSC.route('/manufacturer/edit/', methods=['GET', 'POST']) +@login_required +def edit_manufacturer(manufacturer_id): + """ + Edit a manufacturer + """ + manufacturer = Manufacturer.objects.get({'_id': ObjectId(manufacturer_id)}) + form = ManufacturerForm(obj=manufacturer) + if form.validate_on_submit(): + manufacturer.name = form.name.data + manufacturer.save() + flash('You have successfully edited the manufacturer.') + return redirect(url_for('tysc.list_manufacturers')) + form.name.data = manufacturer.name + return render_template('simpleform.html', action="Edit", + form=form, manufacturer=manufacturer, title="Edit Manufacturer") + + +@TYSC.route('/manufacturer/add', methods=['GET', 'POST']) +@login_required +def add_manufacturer(): + """ + Add a manufacturer + :return: + """ + form = ManufacturerForm() + if form.validate_on_submit(): + manufacturer = Manufacturer() + manufacturer.name = form.name.data + try: + manufacturer.save() + flash('You have successfully added a new manufacturer.') + except PyMongoError: + flash('Error: manufacturer name already exists.') + return redirect(url_for('tysc.list_manufacturers')) + return render_template('simpleform.html', action="Add", + form=form, title="Add Manufacturer") + + +@TYSC.route('/manufacturer/delete/', methods=['GET', 'POST']) +@login_required +def delete_manufacturer(manufacturer_id): + """ + Delete a manufacturer + :param manufacturer_id: + :return: + """ + manufacturer = Manufacturer.objects.raw({'_id': ObjectId(manufacturer_id)}) + if manufacturer: + manufacturer.delete() + flash('You have successfully deleted the manufacturer.') + return redirect(url_for('tysc.list_manufacturers')) + + +@TYSC.route('/cardsets') +@login_required +def list_card_sets(): + """ + List card sets. + :return: + """ + card_sets = CardSet.objects.all() + return render_template('tysc/cardsets.html', card_sets=card_sets, title="Card Sets") + + +@TYSC.route('/parallelsets') +@login_required +def list_parallel_sets(): + """ + List card sets. + :return: + """ + parallel_sets = ParallelSet.objects.all() + return render_template('tysc/parallelsets.html', + parallel_sets=parallel_sets, title="Parallel Sets") + + +@TYSC.route('/insertsets') +@login_required +def list_insert_sets(): + """ + List card sets. + :return: + """ + insert_sets = InsertSet.objects.all() + return render_template('tysc/insertsets.html', insert_sets=insert_sets, title="Inserts") + + +@TYSC.route('/cards') +@login_required +def list_cards(): + """ + List card sets. + :return: + """ + cards = Card.objects.all() + return render_template('tysc/cards.html', cards=cards, title="Cards") diff --git a/flask/kontor/version.py b/flask/kontor/version.py new file mode 100644 index 0000000..044c225 --- /dev/null +++ b/flask/kontor/version.py @@ -0,0 +1,2 @@ +"""This module declares the version of the Kontor application.""" +__version__ = '0.0.7' diff --git a/flask/pylint.cfg b/flask/pylint.cfg new file mode 100644 index 0000000..e08fe3a --- /dev/null +++ b/flask/pylint.cfg @@ -0,0 +1,382 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +init-hook='sys.path = list(); sys.path.append("./lib/python3.4/site-packages/")' + +# Profiled execution. +profile=no + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Allow optimization of some AST trees. This will activate a peephole AST +# optimizer, which will apply various small optimizations. For instance, it can +# be used to obtain the result of joining multiple strings with the addition +# operator. Joining a lot of strings can lead to a maximum recursion error in +# Pylint and this flag can prevent that. It has one side effect, the resulting +# AST will be different than the one from reality. +optimize-ast=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=W1601,W1606,W1604,W1630,E1605,E1604,W1636,W1603,W1633,W1610,W1615,W1626,E1606,W0704,I0020,W1622,E1608,W1612,W1638,W1614,W1616,W1605,W1613,W1634,E1603,I0021,W1640,W1621,W1625,E1602,W1639,W1602,W1620,W1617,W1624,W1609,W1618,E1601,E1607,W1637,W1632,W1629,W1635,W1611,W1623,W1608,W1627,W1628,W1607,W1619,E1101,R0201,R0903,R0801,locally-disabled + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=parseable + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". +files-output=no + +# Tells whether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Add a comment according to your evaluation note. This is used by the global +# evaluation report (RP0004). +comment=no + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis +ignored-modules= + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). +ignored-classes=SQLObject + +# When zope mode is activated, add a predefined set of Zope acquired attributes +# to generated-members. +zope=no + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E0201 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST,acl_users,aq_parent + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_$|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=100 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[BASIC] + +# Required attributes for module, separated by a comma +#required-attributes= + +# List of builtins function names that should not be used, separated by a comma +bad-functions=map,filter + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=yes + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=__.*__ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[CLASSES] + +# List of interface methods to ignore, separated by a comma. This is used for +# instance to not check methods defines in Zope's Interface base class. +#ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=stringprep,optparse + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/flask/requirements.txt b/flask/requirements.txt new file mode 100644 index 0000000..f596076 --- /dev/null +++ b/flask/requirements.txt @@ -0,0 +1,11 @@ +flask +flask-login +flask-bootstrap +Flask-WTF +flask-testing +pymodm +nose +nose-htmloutput +coverage +pylint + diff --git a/flask/tests/__init__.py b/flask/tests/__init__.py new file mode 100644 index 0000000..8506c57 --- /dev/null +++ b/flask/tests/__init__.py @@ -0,0 +1,38 @@ +from flask_testing import TestCase +from kontor import create_app +from kontor.models import User + + +class TestBase(TestCase): + + def create_app(self): + + # pass in test configuration + config_name = 'testing' + app = create_app(config_name) + return app + + def setUp(self): + """ + Will be called before every test + """ + # create test admin user + admin = User() + admin.username="admin" + admin.password="admin2016" + admin.is_admin=True + admin.save() + + # create test non-admin user + employee = User() + employee.username="test_user" + employee.password="test2016" + employee.save() + + def tearDown(self): + """ + Will be called after every test + """ + users = User.objects.all() + for user in users: + user.delete() diff --git a/flask/tests/test_comics_model.py b/flask/tests/test_comics_model.py new file mode 100644 index 0000000..2a5dcc5 --- /dev/null +++ b/flask/tests/test_comics_model.py @@ -0,0 +1,51 @@ +""" +This module cantains tests for the comic data model +""" +import unittest +from . import TestBase +from kontor.comics.models import Artist, Comic, Publisher + + +class TestComicsModel(TestBase): + """This TestCase contains tests for comic data model.""" + + def setUp(self): + publisher = Publisher() + publisher.name = "Publisher1" + publisher.save() + artist = Artist() + artist.name = "Artist1" + artist.save() + comic = Comic() + comic.title = "Title1" + comic.save() + + def tearDown(self): + for comic in Comic.objects.all(): + comic.delete() + for artist in Artist.objects.all(): + artist.delete() + for publisher in Publisher.objects.all(): + publisher.delete() + + def test_comic_model(self): + comic_list = Comic.objects.all() + self.assertEqual(comic_list.count(), 1) + + def test_publisher_model(self): + self.assertEqual(Publisher.objects.all().count(), 1) + + def test_artist_model(self): + self.assertEqual(Artist.objects.all().count(), 1) + + def test_assign_publisher(self): + comic = Comic.objects.first() + publisher = Publisher.objects.first() + comic.publisher = publisher + comic.save() + self.assertEqual(publisher.name, comic.publisher.name) + self.assertEqual(publisher.comics.count(), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/flask/tests/test_comics_view.py b/flask/tests/test_comics_view.py new file mode 100644 index 0000000..e33fd79 --- /dev/null +++ b/flask/tests/test_comics_view.py @@ -0,0 +1,44 @@ +"""This module contains tests for Comic related urls.""" +import unittest +from flask import url_for +from . import TestBase + + +class TestComicsViews(TestBase): + + def test_comics_index(self): + """ + Test that comics page is inaccessible without login + and redirects to login page then to comics page + """ + target_url = url_for('comic.list_comics') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_comics_artist(self): + """ + Test that comics page is inaccessible without login + and redirects to login page then to comics page + """ + target_url = url_for('comic.list_artists') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_comics_publisher(self): + """ + Test that comics page is inaccessible without login + and redirects to login page then to comics page + """ + target_url = url_for('comic.list_publishers') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + +if __name__ == '__main__': + unittest.main() diff --git a/flask/tests/test_config.txt b/flask/tests/test_config.txt new file mode 100644 index 0000000..2b847b0 --- /dev/null +++ b/flask/tests/test_config.txt @@ -0,0 +1,4 @@ +# instance/config.py + +SECRET_KEY = 'p9Bv<3Eid9%$i01' + diff --git a/flask/tests/test_kontor_model.py b/flask/tests/test_kontor_model.py new file mode 100644 index 0000000..0120475 --- /dev/null +++ b/flask/tests/test_kontor_model.py @@ -0,0 +1,14 @@ +""" +This module cantains tests for the general Kontor data model. +""" +from . import TestBase +from kontor.models import User + + +class TestKontorModel(TestBase): + """This TestCase contains tests for users.""" + def test_user_model(self): + """ + Test number of records in User collection + """ + self.assertEqual(User.objects.all().count(), 2) diff --git a/flask/tests/test_kontor_view.py b/flask/tests/test_kontor_view.py new file mode 100644 index 0000000..101ae2f --- /dev/null +++ b/flask/tests/test_kontor_view.py @@ -0,0 +1,58 @@ +"""This module contains tests for Comic related urls.""" +import unittest +from flask import url_for +from . import TestBase + + +class TestKontorViews(TestBase): + + def test_homepage_view(self): + """ + Test that homepage is accessible without login + """ + response = self.client.get(url_for('home.homepage')) + self.assertEqual(response.status_code, 200) + + def test_login_view(self): + """ + Test that login page is accessible without login + """ + response = self.client.get(url_for('auth.login')) + self.assertEqual(response.status_code, 200) + + def test_logout_view(self): + """ + Test that logout link is inaccessible without login + and redirects to login page then to logout + """ + target_url = url_for('auth.logout') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_dashboard_view(self): + """ + Test that dashboard is inaccessible without login + and redirects to login page then to dashboard + """ + target_url = url_for('home.dashboard') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_admin_dashboard_view(self): + """ + Test that dashboard is inaccessible without login + and redirects to login page then to dashboard + """ + target_url = url_for('home.admin_dashboard') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + +if __name__ == '__main__': + unittest.main() diff --git a/flask/tests/test_library_model.py b/flask/tests/test_library_model.py new file mode 100644 index 0000000..635bb58 --- /dev/null +++ b/flask/tests/test_library_model.py @@ -0,0 +1,49 @@ +""" +This module cantains tests for the comic data model +""" +import unittest +from . import TestBase +from kontor.library.models import Author, Publisher, Book + + +class TestComicsModel(TestBase): + """This TestCase contains tests for comic data model.""" + + def setUp(self): + publisher = Publisher() + publisher.name = "Publisher1" + publisher.save() + author = Author() + author.name = "Autor1" + author.save() + book = Book() + book.title = "Title1" + book.save() + + def tearDown(self): + for book in Book.objects.all(): + book.delete() + for author in Author.objects.all(): + author.delete() + for publisher in Publisher.objects.all(): + publisher.delete() + + def test_comic_model(self): + book_list = Book.objects.all() + self.assertEqual(book_list.count(), 1) + + def test_publisher_model(self): + self.assertEqual(Publisher.objects.all().count(), 1) + + def test_artist_model(self): + self.assertEqual(Author.objects.all().count(), 1) + + def test_assign_publisher(self): + book = Book.objects.first() + publisher = Publisher.objects.first() + book.publisher = publisher + book.save() + self.assertEqual(publisher.name, book.publisher.name) + +if __name__ == '__main__': + unittest.main() diff --git a/flask/tests/test_library_view.py b/flask/tests/test_library_view.py new file mode 100644 index 0000000..51b2c30 --- /dev/null +++ b/flask/tests/test_library_view.py @@ -0,0 +1,44 @@ +"""This module contains tests for Comic related urls.""" +import unittest +from flask import url_for +from . import TestBase + + +class TestLibraryViews(TestBase): + + def test_library_index(self): + """ + Test that comics page is inaccessible without login + and redirects to login page then to comics page + """ + target_url = url_for('library.list_books') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_library_author(self): + """ + Test that comics page is inaccessible without login + and redirects to login page then to comics page + """ + target_url = url_for('library.list_authors') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_library_publisher(self): + """ + Test that comics page is inaccessible without login + and redirects to login page then to comics page + """ + target_url = url_for('library.list_publishers') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + +if __name__ == '__main__': + unittest.main() diff --git a/flask/tests/test_tysc_model.py b/flask/tests/test_tysc_model.py new file mode 100644 index 0000000..228cc49 --- /dev/null +++ b/flask/tests/test_tysc_model.py @@ -0,0 +1,53 @@ +""" +This module cantains tests for the TYSC data model +""" +import unittest +from . import TestBase +from kontor.tysc import initialize_model +from kontor.tysc.models import Sport, Position, Team, Player +from kontor.tysc.models import Manufacturer, CardSet, ParallelSet, InsertSet, Card + + +class TestTYSCModel(TestBase): + """ + This TestCase contains tests for TYSC data model. + """ + _initialize_db_ = True + + def setUp(self): + if self._initialize_db_: + initialize_model() + self._initialize_db_ = False + + def tearDown(self): + pass + + def test_sport_model(self): + self.assertEqual(Sport.objects.all().count(), 4) + + def test_position_model(self): + self.assertEqual(Position.objects.all().count(), 25) + + def test_team_model(self): + self.assertEqual(Team.objects.all().count(), 122) + + def test_manufacturer_model(self): + self.assertEqual(Manufacturer.objects.all().count(), 8) + + def test_cardset_model(self): + self.assertEqual(CardSet.objects.all().count(), 11) + + def test_parallelset_model(self): + self.assertEqual(ParallelSet.objects.all().count(), 3) + + def test_insertset_model(self): + self.assertEqual(InsertSet.objects.all().count(), 1) + + def test_playerset_model(self): + self.assertEqual(Player.objects.all().count(), 36) + + def test_card_model(self): + self.assertEqual(Card.objects.all().count(), 36) + +if __name__ == '__main__': + unittest.main() diff --git a/flask/tests/test_tysc_view.py b/flask/tests/test_tysc_view.py new file mode 100644 index 0000000..11fc222 --- /dev/null +++ b/flask/tests/test_tysc_view.py @@ -0,0 +1,110 @@ +"""This module contains tests for TradeYourSportsCards related urls.""" +import unittest +from flask import url_for +from . import TestBase + + +class TestTYSCViews(TestBase): + + def test_tysc_index(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_cards') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_sport(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_sports') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_team(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_teams') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_player(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_players') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_manufacturer(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_manufacturers') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_card_set(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_card_sets') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_parallel_set(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_parallel_sets') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_insert_set(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_insert_sets') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + def test_tysc_card(self): + """ + Test that TYSC page is inaccessible without login + and redirects to login page then to TYSC page + """ + target_url = url_for('tysc.list_cards') + redirect_url = url_for('auth.login', next=target_url) + response = self.client.get(target_url) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_url) + + +if __name__ == '__main__': + unittest.main() From 5c261529fc4ec7cb15869b40a31efc7d9c48e935 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 22:34:21 +0100 Subject: [PATCH 16/26] import from kontor-flask branch develop/0.1.0 --- flask/.gitignore | 2 + flask/README.md | 2 + flask/app.py | 5 + flask/config.py | 47 -- flask/docs/Makefile | 20 - flask/docs/conf.py | 200 --------- flask/docs/index.rst | 20 - flask/kontor/__init__.py | 115 +++-- flask/kontor/admin/__init__.py | 4 - flask/kontor/admin/forms.py | 17 - flask/kontor/admin/views.py | 63 --- flask/kontor/api/__init__.py | 5 + flask/kontor/api/routes.py | 30 ++ flask/kontor/auth/__init__.py | 26 +- flask/kontor/auth/forms.py | 50 --- flask/kontor/auth/models.py | 45 ++ flask/kontor/auth/views.py | 79 ---- flask/kontor/comics/__init__.py | 12 +- flask/kontor/comics/api.py | 28 ++ flask/kontor/comics/forms.py | 31 -- flask/kontor/comics/models.py | 51 --- flask/kontor/comics/routes.py | 16 + .../kontor/comics/templates/comics/list.html | 28 ++ .../kontor/comics/templates/comics/view.html | 27 ++ flask/kontor/comics/views.py | 222 ---------- flask/kontor/config.py | 10 + flask/kontor/extensions.py | 5 + flask/kontor/home/__init__.py | 3 - flask/kontor/home/views.py | 39 -- flask/kontor/library/__init__.py | 3 - flask/kontor/library/forms.py | 35 -- flask/kontor/library/models.py | 55 --- flask/kontor/library/views.py | 228 ---------- flask/kontor/main/__init__.py | 5 + flask/kontor/main/routes.py | 18 + flask/kontor/media/__init__.py | 9 + flask/kontor/media/api.py | 18 + flask/kontor/media/models.py | 43 ++ flask/kontor/media/routes.py | 16 + .../templates/media/mediafile_detail.html | 29 ++ .../media/templates/media/mediafile_list.html | 30 ++ flask/kontor/models.py | 101 ++--- flask/kontor/office/__init__.py | 3 - flask/kontor/static/css/style.css | 126 ------ flask/kontor/templates/admin/users/user.html | 21 - flask/kontor/templates/admin/users/users.html | 58 --- flask/kontor/templates/auth/login.html | 18 - flask/kontor/templates/auth/register.html | 14 - flask/kontor/templates/base.html | 157 +++---- flask/kontor/templates/comics/artists.html | 58 --- flask/kontor/templates/comics/comics.html | 66 --- flask/kontor/templates/comics/publishers.html | 58 --- .../templates/home/admin_dashboard.html | 31 -- flask/kontor/templates/home/dashboard.html | 20 - flask/kontor/templates/home/index.html | 20 - flask/kontor/templates/index.html | 8 + flask/kontor/templates/library/authors.html | 58 --- flask/kontor/templates/library/books.html | 74 ---- .../kontor/templates/library/publishers.html | 58 --- flask/kontor/templates/simpleform.html | 21 - .../kontor/templates/tysc/manufacturers.html | 58 --- flask/kontor/templates/tysc/players.html | 60 --- flask/kontor/templates/tysc/positions.html | 62 --- flask/kontor/templates/tysc/sports.html | 58 --- flask/kontor/templates/tysc/teams.html | 60 --- flask/kontor/tysc/__init__.py | 377 ---------------- flask/kontor/tysc/forms.py | 76 ---- flask/kontor/tysc/models.py | 155 ------- flask/kontor/tysc/views.py | 405 ------------------ flask/kontor/version.py | 2 - flask/pylint.cfg | 382 ----------------- flask/requirements.txt | 11 - flask/tests/__init__.py | 38 -- flask/tests/test_comics_model.py | 51 --- flask/tests/test_comics_view.py | 44 -- flask/tests/test_config.txt | 4 - flask/tests/test_kontor_model.py | 14 - flask/tests/test_kontor_view.py | 58 --- flask/tests/test_library_model.py | 49 --- flask/tests/test_library_view.py | 44 -- flask/tests/test_tysc_model.py | 53 --- flask/tests/test_tysc_view.py | 110 ----- 82 files changed, 562 insertions(+), 4270 deletions(-) create mode 100644 flask/.gitignore create mode 100644 flask/README.md create mode 100644 flask/app.py delete mode 100644 flask/config.py delete mode 100644 flask/docs/Makefile delete mode 100644 flask/docs/conf.py delete mode 100644 flask/docs/index.rst delete mode 100644 flask/kontor/admin/__init__.py delete mode 100644 flask/kontor/admin/forms.py delete mode 100644 flask/kontor/admin/views.py create mode 100644 flask/kontor/api/__init__.py create mode 100644 flask/kontor/api/routes.py delete mode 100644 flask/kontor/auth/forms.py create mode 100644 flask/kontor/auth/models.py delete mode 100644 flask/kontor/auth/views.py create mode 100644 flask/kontor/comics/api.py delete mode 100644 flask/kontor/comics/forms.py delete mode 100644 flask/kontor/comics/models.py create mode 100644 flask/kontor/comics/routes.py create mode 100644 flask/kontor/comics/templates/comics/list.html create mode 100644 flask/kontor/comics/templates/comics/view.html delete mode 100644 flask/kontor/comics/views.py create mode 100644 flask/kontor/config.py create mode 100644 flask/kontor/extensions.py delete mode 100644 flask/kontor/home/__init__.py delete mode 100644 flask/kontor/home/views.py delete mode 100644 flask/kontor/library/__init__.py delete mode 100644 flask/kontor/library/forms.py delete mode 100644 flask/kontor/library/models.py delete mode 100644 flask/kontor/library/views.py create mode 100644 flask/kontor/main/__init__.py create mode 100644 flask/kontor/main/routes.py create mode 100644 flask/kontor/media/__init__.py create mode 100644 flask/kontor/media/api.py create mode 100644 flask/kontor/media/models.py create mode 100644 flask/kontor/media/routes.py create mode 100644 flask/kontor/media/templates/media/mediafile_detail.html create mode 100644 flask/kontor/media/templates/media/mediafile_list.html delete mode 100644 flask/kontor/office/__init__.py delete mode 100644 flask/kontor/static/css/style.css delete mode 100644 flask/kontor/templates/admin/users/user.html delete mode 100644 flask/kontor/templates/admin/users/users.html delete mode 100644 flask/kontor/templates/auth/login.html delete mode 100644 flask/kontor/templates/auth/register.html delete mode 100644 flask/kontor/templates/comics/artists.html delete mode 100644 flask/kontor/templates/comics/comics.html delete mode 100644 flask/kontor/templates/comics/publishers.html delete mode 100644 flask/kontor/templates/home/admin_dashboard.html delete mode 100644 flask/kontor/templates/home/dashboard.html delete mode 100644 flask/kontor/templates/home/index.html create mode 100644 flask/kontor/templates/index.html delete mode 100644 flask/kontor/templates/library/authors.html delete mode 100644 flask/kontor/templates/library/books.html delete mode 100644 flask/kontor/templates/library/publishers.html delete mode 100644 flask/kontor/templates/simpleform.html delete mode 100644 flask/kontor/templates/tysc/manufacturers.html delete mode 100644 flask/kontor/templates/tysc/players.html delete mode 100644 flask/kontor/templates/tysc/positions.html delete mode 100644 flask/kontor/templates/tysc/sports.html delete mode 100644 flask/kontor/templates/tysc/teams.html delete mode 100644 flask/kontor/tysc/__init__.py delete mode 100644 flask/kontor/tysc/forms.py delete mode 100644 flask/kontor/tysc/models.py delete mode 100644 flask/kontor/tysc/views.py delete mode 100644 flask/kontor/version.py delete mode 100644 flask/pylint.cfg delete mode 100644 flask/requirements.txt delete mode 100644 flask/tests/__init__.py delete mode 100644 flask/tests/test_comics_model.py delete mode 100644 flask/tests/test_comics_view.py delete mode 100644 flask/tests/test_config.txt delete mode 100644 flask/tests/test_kontor_model.py delete mode 100644 flask/tests/test_kontor_view.py delete mode 100644 flask/tests/test_library_model.py delete mode 100644 flask/tests/test_library_view.py delete mode 100644 flask/tests/test_tysc_model.py delete mode 100644 flask/tests/test_tysc_view.py diff --git a/flask/.gitignore b/flask/.gitignore new file mode 100644 index 0000000..259ed2a --- /dev/null +++ b/flask/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.idea diff --git a/flask/README.md b/flask/README.md new file mode 100644 index 0000000..6aad8a5 --- /dev/null +++ b/flask/README.md @@ -0,0 +1,2 @@ +# Kontor Flask + diff --git a/flask/app.py b/flask/app.py new file mode 100644 index 0000000..dc827b3 --- /dev/null +++ b/flask/app.py @@ -0,0 +1,5 @@ +from kontor import create_app + +if __name__ == '__main__': + app = create_app() + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/flask/config.py b/flask/config.py deleted file mode 100644 index 4ea5e78..0000000 --- a/flask/config.py +++ /dev/null @@ -1,47 +0,0 @@ -# config.py - - -class Config(object): - """ - Common configurations - """ - - # Put any configurations here that are common across all environments - host = '127.0.0.1' - port = 8500 - database = 'kontor' - - -class DevelopmentConfig(Config): - """ - Development configurations - """ - - DEBUG = True - host = '0.0.0.0' - database = 'kontor_dev' - - -class ProductionConfig(Config): - """ - Production configurations - """ - - DEBUG = False - -class TestingConfig(Config): - """ - Testing configurations - """ - - TESTING = True - DEBUG = True - port = 8600 - database = 'kontor_test' - -app_config = { - 'development': DevelopmentConfig, - 'production': ProductionConfig, - 'testing': TestingConfig -} - diff --git a/flask/docs/Makefile b/flask/docs/Makefile deleted file mode 100644 index d7218b4..0000000 --- a/flask/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = KontorFlask -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/flask/docs/conf.py b/flask/docs/conf.py deleted file mode 100644 index 7278f2d..0000000 --- a/flask/docs/conf.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# Kontor Flask documentation build configuration file, created by -# sphinx-quickstart on Mon Nov 6 08:53:04 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.doctest', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.mathjax', - 'sphinx.ext.ifconfig', - 'sphinx.ext.viewcode'] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = 'Kontor Flask' -copyright = '2017, Thomas Peetz' -author = 'Thomas Peetz' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.1' -# The full version, including alpha/beta/rc tags. -release = '0.0.7' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = 'de' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# This is required for the alabaster theme -# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - '**': [ - 'relations.html', # needs 'show_related': True theme option to display - 'searchbox.html', - ] -} - - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. -htmlhelp_basename = 'KontorFlaskdoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'KontorFlask.tex', 'Kontor Flask Documentation', - 'Thomas Peetz', 'manual'), -] - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'kontorflask', 'Kontor Flask Documentation', - [author], 1) -] - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'KontorFlask', 'Kontor Flask Documentation', - author, 'KontorFlask', 'One line description of project.', - 'Miscellaneous'), -] - - - -# -- Options for Epub output ---------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = project -epub_author = author -epub_publisher = author -epub_copyright = copyright - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# -# epub_identifier = '' - -# A unique identification for the text. -# -# epub_uid = '' - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] - - - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/flask/docs/index.rst b/flask/docs/index.rst deleted file mode 100644 index ca4df98..0000000 --- a/flask/docs/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. Kontor Flask documentation master file, created by - sphinx-quickstart on Mon Nov 6 08:53:04 2017. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to Kontor Flask's documentation! -======================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/flask/kontor/__init__.py b/flask/kontor/__init__.py index 961e132..f8cb55c 100644 --- a/flask/kontor/__init__.py +++ b/flask/kontor/__init__.py @@ -1,74 +1,61 @@ -""" -Module kontor implements Kontor application. -""" +from flask import Flask, render_template +from flask_jwt_extended import JWTManager -from flask import Flask -from flask_bootstrap import Bootstrap -from flask_login import LoginManager -from pymodm import connect +from kontor import config +from kontor.extensions import db, ma +from logging.config import dictConfig -# local imports -from config import app_config -from .version import __version__ +dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }}, + 'root': { + 'level': 'INFO', + 'handlers': ['wsgi'] + } +}) + +app = Flask(__name__) -_LOGIN_MANAGER_ = LoginManager() +def create_app(config_class=config.Config): + app.config.from_object(config_class) + db.init_app(app) + ma.init_app(app) + # Initialize Flask extensions here + app.config["JWT_SECRET_KEY"] = "super-secret" # Change this! + jwt = JWTManager(app) -def get_host(config_name): - """ - Returns host address from configuration. - :param config_name: - :return: host address - """ - host = app_config[config_name].host - return host + with app.app_context(): + # db.create_all() + db.reflect() + # Register blueprints here + from kontor.main import bp as main_bp + app.register_blueprint(main_bp) + from kontor.comics import comics_bp + app.register_blueprint(comics_bp, url_prefix='/comics') + from kontor.api import api_bp + app.register_blueprint(api_bp, url_prefix='/api/v1') + from kontor.comics import comics_api + app.register_blueprint(comics_api, url_prefix='/api/v1/comics') -def get_port(config_name): - """ - Returns port number from configuration. - :param config_name: - :return: port number - """ - port = app_config[config_name].port - return port - - -def create_app(config_name): - """ - Returns Flask application. - :param config_name: - :return: Flask application - """ - app = Flask(__name__, instance_relative_config=True) - app.config.from_object(app_config[config_name]) - app.config.from_pyfile('config.py') - - database = "mongodb://localhost:27017/{}".format(app_config[config_name].database) - connect(database, alias="kontor") - - Bootstrap(app) - _LOGIN_MANAGER_.init_app(app) - _LOGIN_MANAGER_.login_message = "You must be logged in to access this page." - _LOGIN_MANAGER_.login_view = "auth.login" - - from .admin.views import ADMIN as ADMIN_BLUEPRINT - app.register_blueprint(ADMIN_BLUEPRINT, url_prefix='/admin') - - from .auth.views import AUTH as AUTH_BLUEPRINT - app.register_blueprint(AUTH_BLUEPRINT) - - from .home.views import HOME as HOME_BLUEPRINT - app.register_blueprint(HOME_BLUEPRINT) - - from .comics.views import COMIC as COMIC_BLUEPRINT - app.register_blueprint(COMIC_BLUEPRINT, url_prefix='/comics') - - from .library.views import LIBRARY as LIBRARY_BLUEPRINT - app.register_blueprint(LIBRARY_BLUEPRINT, url_prefix='/library') - - from .tysc.views import TYSC as TYSC_BLUEPRINT - app.register_blueprint(TYSC_BLUEPRINT, url_prefix='/tysc') + from kontor.media import media_bp + app.register_blueprint(media_bp, url_prefix='/media') + from kontor.media import media_api + app.register_blueprint(media_api, url_prefix='/api/v1/media') + # from kontor.auth.auth import auth_bp + # from kontor.cart.cart import cart_bp + # from kontor.general.general import general_bp + # app.register_blueprint(auth_bp) + # app.register_blueprint(cart_bp, url_prefix='/cart') + # app.register_blueprint(general_bp) return app diff --git a/flask/kontor/admin/__init__.py b/flask/kontor/admin/__init__.py deleted file mode 100644 index ca00a4b..0000000 --- a/flask/kontor/admin/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Module admin implements administration functions. -""" -from . import views diff --git a/flask/kontor/admin/forms.py b/flask/kontor/admin/forms.py deleted file mode 100644 index d05cfaf..0000000 --- a/flask/kontor/admin/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Define form to edit users -""" -from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField -from wtforms.validators import DataRequired - - -class UserEditForm(FlaskForm): - """ - Form for admin to edit users - """ - email = StringField('Email', validators=[DataRequired()]) - username = StringField('Username', validators=[DataRequired()]) - first_name = StringField('First Name', validators=[DataRequired()]) - last_name = StringField('Last Name', validators=[DataRequired()]) - submit = SubmitField('Submit') diff --git a/flask/kontor/admin/views.py b/flask/kontor/admin/views.py deleted file mode 100644 index e96d8f0..0000000 --- a/flask/kontor/admin/views.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Define routing rules for managing users -""" -from flask import abort, Blueprint, flash, redirect, render_template, url_for -from flask_login import current_user, login_required - -from .forms import UserEditForm -from ..models import User - -ADMIN = Blueprint('admin', __name__) - - -def check_admin(): - """ - Prevent non-admins from accessing the page - """ - if not current_user.is_admin: - abort(403) - - -# User Views -@ADMIN.route('/users') -@login_required -def list_users(): - """ - List all users - """ - check_admin() - - users = User.objects.all() - return render_template('admin/users/users.html', - users=users, title='Users') - - -@ADMIN.route('/users/edit/', methods=['GET', 'POST']) -@login_required -def edit_user(user_id): - """ - Edit an user. - """ - check_admin() - - user = User.objects.get({'username': user_id}) - - # prevent admin from being assigned a department or role - if user.is_admin: - abort(403) - - form = UserEditForm(obj=user) - if form.validate_on_submit(): - user.email = form.email.data - user.username = form.username.data - user.first_name = form.first_name.data - user.last_name = form.last_name.data - user.save() - flash('You have successfully edited an user.') - - # redirect to the roles page - return redirect(url_for('admin.list_users')) - - return render_template('admin/users/user.html', - user=user, form=form, - title='Edit User') diff --git a/flask/kontor/api/__init__.py b/flask/kontor/api/__init__.py new file mode 100644 index 0000000..b26fcb6 --- /dev/null +++ b/flask/kontor/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +api_bp = Blueprint('api_bp', __name__) + +from kontor.api import routes diff --git a/flask/kontor/api/routes.py b/flask/kontor/api/routes.py new file mode 100644 index 0000000..4e9b489 --- /dev/null +++ b/flask/kontor/api/routes.py @@ -0,0 +1,30 @@ +from flask import jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token + +from kontor.api import api_bp + + +@api_bp.route('/') +def index(): + modules = ['comics'] + return jsonify(modules) + + +# Create a route to authenticate your users and return JWTs. The +# create_access_token() function is used to actually generate the JWT. +@api_bp.route("/login", methods=["POST"]) +def login(): + username = request.json.get("username", None) + password = request.json.get("password", None) + if username != "test" or password != "test": + return jsonify({"msg": "Bad username or password"}), 401 + + access_token = create_access_token(identity=username) + return jsonify(access_token=access_token) + + +@api_bp.route('/protected', methods=['GET']) +@jwt_required() +def protected(): + current_user = get_jwt_identity() + return {'message': f'Hello, {current_user}!'} diff --git a/flask/kontor/auth/__init__.py b/flask/kontor/auth/__init__.py index 2d9b1ca..9da7988 100644 --- a/flask/kontor/auth/__init__.py +++ b/flask/kontor/auth/__init__.py @@ -1,4 +1,22 @@ -""" -Module auth implements authentication functions. -""" -from . import views +import bcrypt +from flask import session +from flask_httpauth import HTTPBasicAuth + +from kontor import app +from kontor.auth.models import User + +auth = HTTPBasicAuth() + + +@auth.verify_password +def verify_password(username, password): + if username is None: + return False + # Add your authentication logic here + app.logger.info("login user %s", username) + user = User.query.filter_by(user_name=username).first() + app.logger.info("User: %s", user) + app.logger.info("Stored Password: '%s' Hashed Password: '%s'", user.password, bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())) + if 'user_name' in session and session['user_name'] == username: + return True + return False \ No newline at end of file diff --git a/flask/kontor/auth/forms.py b/flask/kontor/auth/forms.py deleted file mode 100644 index 6e4d6dd..0000000 --- a/flask/kontor/auth/forms.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Contains forms for authentication. -""" -from flask_wtf import FlaskForm -from wtforms import PasswordField, StringField, SubmitField, ValidationError -from wtforms.validators import DataRequired, Email, EqualTo - -from ..models import User - - -class RegistrationForm(FlaskForm): - """ - Form for users to create new account - """ - email = StringField('Email', validators=[DataRequired(), Email()]) - username = StringField('Username', validators=[DataRequired()]) - first_name = StringField('First Name', validators=[DataRequired()]) - last_name = StringField('Last Name', validators=[DataRequired()]) - password = PasswordField('Password', validators=[DataRequired(), - EqualTo('confirm_password') - ]) - confirm_password = PasswordField('Confirm Password') - submit = SubmitField('Register') - - def validate_email(self, field): - """ - Check if email is already in use. - :param field: - :return: - """ - if User.objects.raw({'email': field.data}).count() > 0: - raise ValidationError('Email is already in use.') - - def validate_username(self, field): - """ - check if username is already in use. - :param field: - :return: - """ - if User.objects.raw({'username': field.data}).count() > 0: - raise ValidationError('Username is already in use.') - - -class LoginForm(FlaskForm): - """ - Form for users to login - """ - email = StringField('Email', validators=[DataRequired(), Email()]) - password = PasswordField('Password', validators=[DataRequired()]) - submit = SubmitField('Login') diff --git a/flask/kontor/auth/models.py b/flask/kontor/auth/models.py new file mode 100644 index 0000000..fc3b8d4 --- /dev/null +++ b/flask/kontor/auth/models.py @@ -0,0 +1,45 @@ +from kontor.extensions import db, ma +from sqlalchemy.sql import func + + +class User(db.Model): + # __table__ = db.metadata.tables["publisher"] + __tablename__ = "user" + __table_args__ = {'extend_existing': True} + id = db.Column(db.String, primary_key=True) + created_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_modified_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + version = db.Column(db.Integer) + enabled = db.Column(db.SmallInteger) + email = db.Column(db.String) + first_name = db.Column(db.String) + last_name = db.Column(db.String) + user_name = db.Column(db.String) + password = db.Column(db.String) + token = db.Column(db.String) + token_expired = db.Column(db.SmallInteger) + + def is_token_valid(self): + return self.review == 'b\x01' + + def is_user_enabled(self): + return self.should_download == 'b\x01' + +class Role(db.Model): + __tablename__ = "role" + __table_args__ = {'extend_existing': True} + id = db.Column(db.String, primary_key=True) + created_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_modified_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + version = db.Column(db.Integer) + name = db.Column(db.String) + +class AuthorizationMatrix(db.Model): + __tablename__ = "authorization_matrix" + __table_args__ = {'extend_existing': True} + id = db.Column(db.String, primary_key=True) + created_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_modified_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + version = db.Column(db.Integer) + role_id = db.Column(db.String, db.ForeignKey("role.id")) + user_id = db.Column(db.String, db.ForeignKey("user.id")) diff --git a/flask/kontor/auth/views.py b/flask/kontor/auth/views.py deleted file mode 100644 index 217ae18..0000000 --- a/flask/kontor/auth/views.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Define routing rules for registering users and login and logout. -""" -from flask import Blueprint, flash, redirect, render_template, url_for -from flask_login import login_required, login_user, logout_user -from .forms import LoginForm, RegistrationForm -from ..models import User - -AUTH = Blueprint('auth', __name__) - - -@AUTH.route('/register', methods=['GET', 'POST']) -def register(): - """ - Handle requests to the /register route - Add an employee to the database through the registration form - """ - form = RegistrationForm() - if form.validate_on_submit(): - user = User() - user.email = form.email.data - user.username = form.username.data - user.first_name = form.first_name.data - user.last_name = form.last_name.data - user.password = form.password.data - # add employee to the database - user.save() - flash('You have successfully registered! You may now login.') - - # redirect to the login page - return redirect(url_for('auth.login')) - - # load registration template - return render_template('auth/register.html', form=form, title='Register') - - -@AUTH.route('/login', methods=['GET', 'POST']) -def login(): - """ - Handle requests to the /login route - Validate given email with matching password - :return: - """ - form = LoginForm() - if form.validate_on_submit(): - - # check whether employee exists in the database and whether - # the password entered matches the password in the database - user = User.objects.get({'email': form.email.data}) - if user is not None and user.verify_password( - form.password.data): - # log employee in - login_user(user) - - # redirect to the appropriate dashboard page - if user.is_admin: - return redirect(url_for('home.admin_dashboard')) - else: - return redirect(url_for('home.dashboard')) - - # when login details are incorrect - else: - flash('Invalid email or password.') - - # load login template - return render_template('auth/login.html', form=form, title='Login') - -@AUTH.route('/logout') -@login_required -def logout(): - """ - Handle requests to the /logout route - Log an employee out through the logout link - """ - logout_user() - flash('You have successfully been logged out.') - - # redirect to the login page - return redirect(url_for('auth.login')) diff --git a/flask/kontor/comics/__init__.py b/flask/kontor/comics/__init__.py index e947d82..12bb371 100644 --- a/flask/kontor/comics/__init__.py +++ b/flask/kontor/comics/__init__.py @@ -1,3 +1,9 @@ -""" -Define routing rules for comic related information -""" +from flask import Blueprint + +comics_bp = Blueprint('comics_bp', __name__, + template_folder='templates', + static_folder='static', static_url_path='assets') +comics_api = Blueprint('comics_api', __name__) + +from kontor.comics import routes +from kontor.comics import api diff --git a/flask/kontor/comics/api.py b/flask/kontor/comics/api.py new file mode 100644 index 0000000..22bca28 --- /dev/null +++ b/flask/kontor/comics/api.py @@ -0,0 +1,28 @@ +from flask import Blueprint, render_template, jsonify + +from kontor.comics import comics_api +from kontor.models import Comic, comics_schema, Publisher, comic_schema, publisher_schema, publishers_schema + + +@comics_api.route('/') +def index(): + comics = Comic.query.all() + return comics_schema.dump(comics) + + +@comics_api.route('/comic/') +def view(comic_id): + comic = Comic.query.get(comic_id) + return comic_schema.dump(comic) + + +@comics_api.route('/publisher/') +def publisher_all(): + publishers = Publisher.query.all() + return publishers_schema.dump(publishers) + + +@comics_api.route('/publisher/') +def publisher_detail(publisher_id): + publisher = Publisher.query.get(publisher_id) + return publisher_schema.dump(publisher) \ No newline at end of file diff --git a/flask/kontor/comics/forms.py b/flask/kontor/comics/forms.py deleted file mode 100644 index 0bae45f..0000000 --- a/flask/kontor/comics/forms.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Define form to edit publisher, artists and comics""" -from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField, BooleanField, SelectField -from wtforms.validators import DataRequired -from bson import ObjectId - - -class PublisherForm(FlaskForm): - """ - Form to add and edit a Comic publisher - """ - name = StringField('Name', validators=[DataRequired()]) - submit = SubmitField('Submit') - - -class ArtistForm(FlaskForm): - """ - Form to add and edit a Comic publisher - """ - name = StringField('Name', validators=[DataRequired()]) - submit = SubmitField('Submit') - - -class ComicForm(FlaskForm): - """ - Form to add and edit Comics - """ - title = StringField('Title', validators=[DataRequired()]) - publisher = SelectField('Publisher', coerce=ObjectId) - current_order = BooleanField('Current Order') - submit = SubmitField('Submit') diff --git a/flask/kontor/comics/models.py b/flask/kontor/comics/models.py deleted file mode 100644 index e39e744..0000000 --- a/flask/kontor/comics/models.py +++ /dev/null @@ -1,51 +0,0 @@ -"""This modules declares the model for Comic related information.""" - -from flask import current_app -from pymongo.write_concern import WriteConcern -from pymodm import MongoModel, fields - - -class Publisher(MongoModel): - """Class Publisher represents a publisher of a comic.""" - name = fields.CharField() - - def __str__(self): - return "Publisher({})".format(self.name) - - @property - def comics(self): - """ - Return list of comics which has reference to this publisher - :return: - """ - comics = Comic.objects.raw({'publisher': self.pk}) - current_app.logger.debug(comics) - return comics - - class Meta: - """Sets the connection and connections details.""" - connection_alias = 'kontor' - write_concern = WriteConcern(j=True) - - -class Artist(MongoModel): - """Class Artist represents a comic artist.""" - name = fields.CharField() - - class Meta: - """Sets the connection and connections details.""" - connection_alias = 'kontor' - write_concern = WriteConcern(j=True) - - -class Comic(MongoModel): - """Class Comic represents a comic.""" - title = fields.CharField() - publisher = fields.ReferenceField(Publisher) - current_order = fields.BooleanField(default=False) - completed = fields.BooleanField(default=False) - - class Meta: - """Sets the connection and connections details.""" - connection_alias = 'kontor' - write_concern = WriteConcern(j=True) diff --git a/flask/kontor/comics/routes.py b/flask/kontor/comics/routes.py new file mode 100644 index 0000000..0b0f53e --- /dev/null +++ b/flask/kontor/comics/routes.py @@ -0,0 +1,16 @@ +from flask import Blueprint, render_template + +from kontor.comics import comics_bp +from kontor.models import Comic + + +@comics_bp.route('/') +def index(): + comics = Comic.query.all() + return render_template('comics/list.html', comics=comics) + + +@comics_bp.route('/comic/') +def view(comic_id): + comic = Comic.query.get(comic_id) + return render_template('comics/view.html', comic=comic) diff --git a/flask/kontor/comics/templates/comics/list.html b/flask/kontor/comics/templates/comics/list.html new file mode 100644 index 0000000..c0f4922 --- /dev/null +++ b/flask/kontor/comics/templates/comics/list.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block content %} + +

{% block title %} Comics {% endblock %}

+
+
+

Comics Blueprint

+ {% for comic in comics %} +
+ +

+ {{ comic.title }} +

+
+

{{ comic.publisher.name }}

+
+

Created

+

{{ comic.created_date }}

+

Modified

+

{{ comic.last_modified_date }}

+
+

Version: {{ comic.version }}

+

Review: {{ comic.review }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/flask/kontor/comics/templates/comics/view.html b/flask/kontor/comics/templates/comics/view.html new file mode 100644 index 0000000..b70da14 --- /dev/null +++ b/flask/kontor/comics/templates/comics/view.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block content %} + +

{% block title %} Comics {% endblock %}

+
+
+

Comics Blueprint

+
+ +

+ {{ comic.title }} +

+
+ +

{{ comic.id }}

+
+
+

Created

+

{{ comic.created_date }}

+

Modified

+

{{ comic.last_modified_date }}

+
+

Version: {{ comic.version }}

+
+
+{% endblock %} diff --git a/flask/kontor/comics/views.py b/flask/kontor/comics/views.py deleted file mode 100644 index 090b032..0000000 --- a/flask/kontor/comics/views.py +++ /dev/null @@ -1,222 +0,0 @@ -""" -Define routing rules for comics, publisher and artists -""" -from flask import Blueprint, flash, redirect, render_template, url_for -from flask_login import login_required -from bson import ObjectId -from pymongo.errors import PyMongoError -from .forms import ComicForm, PublisherForm, ArtistForm -from .models import Comic, Publisher, Artist - - -COMIC = Blueprint('comic', __name__) - - -@COMIC.route('/artists') -@login_required -def list_artists(): - """ - List all artists - :return: - """ - artists = Artist.objects.all() - return render_template('comics/artists.html', - artists=artists, title="Artists") - - -@COMIC.route('/artists/edit/', methods=['GET', 'POST']) -@login_required -def edit_artist(artist_id): - """ - Edit a comic artist - """ - artist = Artist.objects.get({'_id': ObjectId(artist_id)}) - form = ArtistForm(obj=artist) - if form.validate_on_submit(): - artist.name = form.name.data - artist.save() - flash('You have successfully edited the artist.') - return redirect(url_for('comic.list_artists')) - form.name.data = artist.name - return render_template('simpleform.html', action="Edit", - form=form, title="Edit Artist") - - -@COMIC.route('/artists/add', methods=['GET', 'POST']) -@login_required -def add_artist(): - """ - Add a artist - :return: - """ - form = ArtistForm() - if form.validate_on_submit(): - artist = Artist() - artist.name = form.name.data - try: - # add publisher to the database - artist.save() - flash('You have successfully added a new artist.') - except PyMongoError: - # in case publisher name already exists - flash('Error: artist name already exists.') - return redirect(url_for('comic.list_artists')) - return render_template('simpleform.html', action="Add", - form=form, title="Add Artist") - - -@COMIC.route('/artists/delete/', methods=['GET', 'POST']) -@login_required -def delete_artist(artist_id): - """ - Delete a comic artist - :param artist_id: - :return: - """ - artist = Artist.objects.raw({'_id': ObjectId(artist_id)}) - if artist: - artist.delete() - flash('You have successfully deleted the comic artist.') - return redirect(url_for('comic.list_artists')) - - -@COMIC.route('/publishers') -@login_required -def list_publishers(): - """ - List all publishers - :return: - """ - publishers = Publisher.objects.all() - return render_template('comics/publishers.html', - publishers=publishers, title="Publishers") - - -@COMIC.route('/publishers/add', methods=['GET', 'POST']) -@login_required -def add_publisher(): - """ - Add a publisher to the database - :return: - """ - form = PublisherForm() - if form.validate_on_submit(): - publisher = Publisher() - publisher.name = form.name.data - try: - # add publisher to the database - publisher.save() - flash('You have successfully added a new publisher.') - except PyMongoError: - # in case publisher name already exists - flash('Error: publisher name already exists.') - return redirect(url_for('comic.list_publishers')) - return render_template('simpleform.html', action="Add", - form=form, title="Add Publisher") - - -@COMIC.route('/publishers/edit/', methods=['GET', 'POST']) -@login_required -def edit_publisher(publisher_id): - """ - Edit a publisher - """ - publisher = Publisher.objects.get({'_id': ObjectId(publisher_id)}) - form = PublisherForm(obj=publisher) - if form.validate_on_submit(): - publisher.name = form.name.data - publisher.save() - flash('You have successfully edited the publisher.') - return redirect(url_for('comic.list_publishers')) - form.name.data = publisher.name - return render_template('simpleform.html', action="Edit", - form=form, title="Edit Publisher") - - -@COMIC.route('/publishers/delete/', methods=['GET', 'POST']) -@login_required -def delete_publisher(publisher_id): - """ - Delete a publisher - :param publisher_id: ObjectId of publisher - :return: - """ - publisher = Publisher.objects.raw({'_id': ObjectId(publisher_id)}) - if publisher: - publisher.delete() - flash('You have successfully deleted the publisher.') - return redirect(url_for('comic.list_publishers')) - - -@COMIC.route('/comics') -@login_required -def list_comics(): - """ - List all comics - :return: - """ - comics = Comic.objects.all() - return render_template('comics/comics.html', - comics=comics, title="Comics") - - -@COMIC.route('/comics/edit/', methods=['GET', 'POST']) -@login_required -def edit_comic(comic_id): - """ - Edit a comic - """ - comic = Comic.objects.get({'_id': ObjectId(comic_id)}) - form = ComicForm(obj=comic) - form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] - form.publisher.default = comic.publisher.pk - form.publisher.process_data(comic.publisher.pk) - if form.validate_on_submit(): - comic.title = form.title.data - comic.current_order = form.current_order.data - comic.publisher = form.publisher.data - comic.save() - flash('You have successfully edited the comic.') - return redirect(url_for('comic.list_comics')) - form.title.data = comic.title - form.current_order.data = comic.current_order - return render_template('simpleform.html', action="Edit", - form=form, title="Edit Comic") - - -@COMIC.route('/comics/add', methods=['GET', 'POST']) -@login_required -def add_comic(): - """ - Add a comic - :return: - """ - form = ComicForm() - form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] - if form.validate_on_submit(): - comic = Comic() - comic.title = form.title.data - comic.publisher = form.publisher.data - try: - comic.save() - flash('You have successfully added a new comic.') - except PyMongoError: - flash('Error: comic title already exists.') - return redirect(url_for('comic.list_comics')) - return render_template('simpleform.html', action="Add", - form=form, title="Add Comic") - - -@COMIC.route('/comics/delete/', methods=['GET', 'POST']) -@login_required -def delete_comic(comic_id): - """ - Delete a comic - :param comic_id: - :return: - """ - comic = Comic.objects.raw({'_id': ObjectId(comic_id)}) - if comic: - comic.delete() - flash('You have successfully deleted the comic.') - return redirect(url_for('comic.list_comics')) diff --git a/flask/kontor/config.py b/flask/kontor/config.py new file mode 100644 index 0000000..8382f66 --- /dev/null +++ b/flask/kontor/config.py @@ -0,0 +1,10 @@ +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') + # SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or 'sqlite:///' + os.path.join(basedir, 'kontor.db') + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or 'mariadb+mariadbconnector://kontor:kontor@localhost/kontor' + SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/flask/kontor/extensions.py b/flask/kontor/extensions.py new file mode 100644 index 0000000..461bd5d --- /dev/null +++ b/flask/kontor/extensions.py @@ -0,0 +1,5 @@ +import flask_sqlalchemy +from flask_marshmallow import Marshmallow + +db = flask_sqlalchemy.SQLAlchemy() +ma = Marshmallow() diff --git a/flask/kontor/home/__init__.py b/flask/kontor/home/__init__.py deleted file mode 100644 index d4468a9..0000000 --- a/flask/kontor/home/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Module for the Kontor homepage. -""" diff --git a/flask/kontor/home/views.py b/flask/kontor/home/views.py deleted file mode 100644 index 4e4d539..0000000 --- a/flask/kontor/home/views.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Define routing rules for homepage and dashboards -""" - -from flask import Blueprint, render_template, abort -from flask_login import login_required, current_user - -HOME = Blueprint('home', __name__) - - -@HOME.route('/') -def homepage(): - """ - Render the homepage template on the / route - """ - return render_template('home/index.html', title="Welcome") - - -@HOME.route('/dashboard') -@login_required -def dashboard(): - """ - Render the dashboard template on the /dashboard route - """ - return render_template('home/dashboard.html', title="Dashboard") - - -@HOME.route('/admin/dashboard') -@login_required -def admin_dashboard(): - """ - Render the admin_dashboard template on the /admin/dashboard route - :return: - """ - # prevent non-admins from accessing the page - if not current_user.is_admin: - abort(403) - - return render_template('home/admin_dashboard.html', title="Dashboard") diff --git a/flask/kontor/library/__init__.py b/flask/kontor/library/__init__.py deleted file mode 100644 index 31eb0bc..0000000 --- a/flask/kontor/library/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Define routing rules for library related information -""" diff --git a/flask/kontor/library/forms.py b/flask/kontor/library/forms.py deleted file mode 100644 index a080781..0000000 --- a/flask/kontor/library/forms.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Define form to edit publisher, artists and books -""" -from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField, SelectField, IntegerField -from wtforms.validators import DataRequired, Optional, NumberRange -from bson import ObjectId - - -class PublisherForm(FlaskForm): - """ - Form to add and edit a Comic publisher - """ - name = StringField('Name', validators=[DataRequired()]) - submit = SubmitField('Submit') - - -class AuthorForm(FlaskForm): - """ - Form to add and edit a Comic publisher - """ - name = StringField('Name', validators=[DataRequired()]) - submit = SubmitField('Submit') - - -class BookForm(FlaskForm): - """ - Form to add and edit Comics - """ - title = StringField('Title', validators=[DataRequired()]) - author = SelectField('Author', coerce=ObjectId) - publisher = SelectField('Publisher', coerce=ObjectId) - isbn = StringField('ISBN', validators=[Optional()]) - year = IntegerField('Year', validators=[NumberRange(min=1970, max=2050)]) - submit = SubmitField('Submit') diff --git a/flask/kontor/library/models.py b/flask/kontor/library/models.py deleted file mode 100644 index 7ca5c58..0000000 --- a/flask/kontor/library/models.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -This module declares the model for the library related information. -""" -from pymongo.write_concern import WriteConcern -from pymodm import MongoModel, fields - - -class Publisher(MongoModel): - """ - Class Publisher represents a publisher of a book. - """ - name = fields.CharField() - - def __str__(self): - return "Publisher({})".format(self.name) - - class Meta: - """Sets the connection and connections details.""" - connection_alias = 'kontor' - write_concern = WriteConcern(j=True) - - -class Author(MongoModel): - """ - Class Author represents an author of a book. - """ - name = fields.CharField() - - def __str__(self): - return "Author({})".format(self.name) - - class Meta: - """Sets the connection and connections details.""" - connection_alias = 'kontor' - write_concern = WriteConcern(j=True) - - -class Book(MongoModel): - """ - Class book represents a book. - """ - title = fields.CharField() - author = fields.ReferenceField(Author) - publisher = fields.ReferenceField(Publisher) - isbn = fields.CharField(blank=True) - year = fields.IntegerField(blank=True) - edition = fields.CharField() - - def __str__(self): - return "Book({})".format(self.title) - - class Meta: - """Sets the connection and connections details.""" - connection_alias = 'kontor' - write_concern = WriteConcern(j=True) diff --git a/flask/kontor/library/views.py b/flask/kontor/library/views.py deleted file mode 100644 index 1b53eee..0000000 --- a/flask/kontor/library/views.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Define routing rules for book publishers""" -from flask import Blueprint, flash, redirect, render_template, url_for -from flask_login import login_required -from bson import ObjectId -from pymongo.errors import PyMongoError -from .forms import PublisherForm, AuthorForm, BookForm -from .models import Publisher, Author, Book - - -LIBRARY = Blueprint('library', __name__) - - -@LIBRARY.route('/publishers') -@login_required -def list_publishers(): - """ - List all publishers - :return: - """ - publishers = Publisher.objects.all() - return render_template('library/publishers.html', - publishers=publishers, title="Publishers") - - -@LIBRARY.route('/publishers/add', methods=['GET', 'POST']) -@login_required -def add_publisher(): - """ - Add a publisher to the database - :return: - """ - form = PublisherForm() - if form.validate_on_submit(): - publisher = Publisher() - publisher.name = form.name.data - try: - publisher.save() - flash('You have successfully added a new publisher.') - except PyMongoError: - flash('Error: publisher name already exists.') - return redirect(url_for('library.list_publishers')) - return render_template('simpleform.html', action="Add", - form=form, title="Add Publisher") - - -@LIBRARY.route('/publishers/edit/', methods=['GET', 'POST']) -@login_required -def edit_publisher(publisher_id): - """ - Edit a publisher - """ - publisher = Publisher.objects.get({'_id': ObjectId(publisher_id)}) - form = PublisherForm(obj=publisher) - if form.validate_on_submit(): - publisher.name = form.name.data - publisher.save() - flash('You have successfully edited the publisher.') - return redirect(url_for('library.list_publishers')) - form.name.data = publisher.name - return render_template('simpleform.html', action="Edit", - form=form, publisher=publisher, title="Edit Publisher") - - -@LIBRARY.route('/publishers/delete/', methods=['GET', 'POST']) -@login_required -def delete_publisher(publisher_id): - """ - Delete a publisher - :param publisher_id: ObjectId of publisher - :return: - """ - publisher = Publisher.objects.raw({'_id': ObjectId(publisher_id)}) - if publisher: - publisher.delete() - flash('You have successfully deleted the publisher.') - return redirect(url_for('library.list_publishers')) - - -@LIBRARY.route('/authors') -@login_required -def list_authors(): - """ - List all authors - :return: - """ - authors = Author.objects.all() - return render_template('library/authors.html', - authors=authors, title="Authors") - - -@LIBRARY.route('/authors/edit/', methods=['GET', 'POST']) -@login_required -def edit_author(author_id): - """ - Edit a book artist - """ - author = Author.objects.get({'_id': ObjectId(author_id)}) - form = AuthorForm(obj=author) - if form.validate_on_submit(): - author.name = form.name.data - author.save() - flash('You have successfully edited the author.') - return redirect(url_for('library.list_authors')) - form.name.data = author.name - return render_template('simpleform.html', action="Edit", - form=form, author=author, title="Edit Author") - - -@LIBRARY.route('/authors/add', methods=['GET', 'POST']) -@login_required -def add_author(): - """ - Add a author - :return: - """ - form = AuthorForm() - if form.validate_on_submit(): - author = Author() - author.name = form.name.data - try: - author.save() - flash('You have successfully added a new author.') - except PyMongoError: - flash('Error: author name already exists.') - return redirect(url_for('library.list_authors')) - return render_template('simpleform.html', action="Add", - form=form, title="Add Author") - - -@LIBRARY.route('/authors/delete/', methods=['GET', 'POST']) -@login_required -def delete_author(author_id): - """ - Delete a author - :param author_id: - :return: - """ - author = Author.objects.raw({'_id': ObjectId(author_id)}) - if author: - author.delete() - flash('You have successfully deleted the author.') - return redirect(url_for('library.list_authors')) - - -@LIBRARY.route('/books') -@login_required -def list_books(): - """ - List all comics - :return: - """ - books = Book.objects.all() - return render_template('library/books.html', - books=books, title="Books") - - -@LIBRARY.route('/books/edit/', methods=['GET', 'POST']) -@login_required -def edit_book(book_id): - """ - Edit a book - """ - book = Book.objects.get({'_id': ObjectId(book_id)}) - form = BookForm(obj=book) - form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] - if book.publisher: - form.publisher.default = book.publisher.pk - form.publisher.process_data(book.publisher.pk) - form.author.choices = [(a.pk, a.name) for a in Author.objects.all()] - if book.author: - form.author.default = book.author.pk - form.author.process_data(book.author.pk) - if form.validate_on_submit(): - book.title = form.title.data - book.publisher = form.publisher.data - book.author = form.author.data - if form.isbn.data: - book.isbn = form.isbn.data - else: - book.isbn = None - book.year = form.year.data - book.save() - flash('You have successfully edited the book.') - return redirect(url_for('library.list_books')) - form.title.data = book.title - return render_template('simpleform.html', action="Edit", - form=form, book=book, title="Edit Book") - - -@LIBRARY.route('/books/add', methods=['GET', 'POST']) -@login_required -def add_book(): - """ - Add a book - :return: - """ - form = BookForm() - form.publisher.choices = [(p.pk, p.name) for p in Publisher.objects.all()] - form.author.choices = [(a.pk, a.name) for a in Author.objects.all()] - if form.validate_on_submit(): - book = Book() - book.title = form.title.data - book.publisher = form.publisher.data - book.author = form.author.data - book.year = form.year.data - try: - book.save() - flash('You have successfully added a new book.') - except PyMongoError: - flash('Error: book title already exists.') - return redirect(url_for('library.list_books')) - return render_template('simpleform.html', action="Add", - form=form, title="Add Book") - - -@LIBRARY.route('/books/delete/', methods=['GET', 'POST']) -@login_required -def delete_book(book_id): - """ - Delete a book - :param book_id: - :return: - """ - book = Book.objects.raw({'_id': ObjectId(book_id)}) - if book: - book.delete() - flash('You have successfully deleted the book.') - return redirect(url_for('library.list_books')) diff --git a/flask/kontor/main/__init__.py b/flask/kontor/main/__init__.py new file mode 100644 index 0000000..ebdaf6e --- /dev/null +++ b/flask/kontor/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) + +from kontor.main import routes diff --git a/flask/kontor/main/routes.py b/flask/kontor/main/routes.py new file mode 100644 index 0000000..0c2e5dd --- /dev/null +++ b/flask/kontor/main/routes.py @@ -0,0 +1,18 @@ +from flask import render_template, session + +from kontor import app +from kontor.main import bp +from kontor.auth import auth + + +@bp.route('/') +@auth.login_required +def index(): + return render_template('index.html') + +@bp.route('/logout') +def logout(): + app.logger.info("logout") + auth.current_user() + session['user_name'] = None + return render_template('index.html') diff --git a/flask/kontor/media/__init__.py b/flask/kontor/media/__init__.py new file mode 100644 index 0000000..581af5f --- /dev/null +++ b/flask/kontor/media/__init__.py @@ -0,0 +1,9 @@ +from flask import Blueprint + +media_bp = Blueprint('media_bp', __name__, + template_folder='templates', + static_folder='static', static_url_path='assets') +media_api = Blueprint('media_api', __name__) + +from kontor.media import routes +from kontor.media import api diff --git a/flask/kontor/media/api.py b/flask/kontor/media/api.py new file mode 100644 index 0000000..be9b875 --- /dev/null +++ b/flask/kontor/media/api.py @@ -0,0 +1,18 @@ +from flask import Blueprint, render_template, jsonify + +from kontor import app +from kontor.media import media_api +from kontor.media.models import MediaFile, mediafile_schema, mediafiles_schema + + +@media_api.route('/') +def mediafile_list(): + app.logger.info("get all media files") + files = MediaFile.query.all() + return mediafiles_schema.dump(files) + + +@media_api.route('/mediafile/') +def mediafile_detail(file_id): + file = MediaFile.query.get(file_id) + return mediafile_schema.dump(file) diff --git a/flask/kontor/media/models.py b/flask/kontor/media/models.py new file mode 100644 index 0000000..4e54978 --- /dev/null +++ b/flask/kontor/media/models.py @@ -0,0 +1,43 @@ +from kontor.extensions import db, ma +from sqlalchemy.sql import func + + +class MediaFile(db.Model): + # __table__ = db.metadata.tables["publisher"] + __tablename__ = "media_file" + __table_args__ = {'extend_existing': True} + id = db.Column(db.String, primary_key=True) + created_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_modified_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + version = db.Column(db.Integer) + title = db.Column(db.String) + file_name = db.Column(db.String) + url = db.Column(db.String) + path = db.Column(db.String) + cloud_link = db.Column(db.String) + review = db.Column(db.SmallInteger) + should_download = db.Column(db.Boolean) + + def is_review(self): + return self.review == 'b\x00' + + def is_download(self): + return self.should_download == 'b\x00' + + +class MediaFileSchema(ma.SQLAlchemySchema): + class Meta: + model = MediaFile + fields = ("id", "created_date", "last_modified_date", "version", "title", "file_name", "url", "path", "cloud_link", "review", "should_download", "_links") + + # Smart hyperlinking + _links = ma.Hyperlinks( + { + "self": ma.URLFor("media_api.mediafile_detail", values=dict(mediafile_id="")), + "collection": ma.URLFor("media_api.mediafile_list"), + } + ) + + +mediafile_schema = MediaFileSchema() +mediafiles_schema = MediaFileSchema(many=True) diff --git a/flask/kontor/media/routes.py b/flask/kontor/media/routes.py new file mode 100644 index 0000000..a32f5e3 --- /dev/null +++ b/flask/kontor/media/routes.py @@ -0,0 +1,16 @@ +from flask import Blueprint, render_template + +from kontor.media import media_bp +from kontor.media.models import MediaFile + + +@media_bp.route('/') +def mediafile_list(): + files = MediaFile.query.all() + return render_template('media/mediafile_list.html', mediafiles=files) + + +@media_bp.route('/mediafile/') +def mediafile_detail(mediafile_id): + mediafile = MediaFile.query.get(mediafile_id) + return render_template('media/mediafile_detail.html', mediafile=mediafile) diff --git a/flask/kontor/media/templates/media/mediafile_detail.html b/flask/kontor/media/templates/media/mediafile_detail.html new file mode 100644 index 0000000..e3474ae --- /dev/null +++ b/flask/kontor/media/templates/media/mediafile_detail.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block content %} + +

{% block title %} MediaFile {% endblock %}

+
+
+

MediaFile Blueprint

+
+ +

+ {{ mediafile.title }} +

+
+ +

{{ mediafile.id }}

+
+
+

Created

+

{{ mediafile.created_date }}

+

Modified

+

{{ mediafile.last_modified_date }}

+
+

Version: {{ mediafile.version }}

+

Review: {{ mediafile.is_review() }}

+

Should Download: {{mediafile.is_download() }}

+
+
+{% endblock %} diff --git a/flask/kontor/media/templates/media/mediafile_list.html b/flask/kontor/media/templates/media/mediafile_list.html new file mode 100644 index 0000000..2d7d29f --- /dev/null +++ b/flask/kontor/media/templates/media/mediafile_list.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} + +{% block content %} + +

{% block title %} MediaFile {% endblock %}

+
+
+

MediaFile Blueprint

+ {% for mediafile in mediafiles %} +
+ +

+ {{ mediafile.title }} +

+
+

{{ mediafile.id }}

+
+

Created

+

{{ mediafile.created_date }}

+

Modified

+

{{ mediafile.last_modified_date }}

+
+

Version: {{ mediafile.version }}

+

Review: {{ mediafile.is_review() }}

+

Should Download: {{ mediafile.is_download() }}

+

Links: {{ mediafile._links }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/flask/kontor/models.py b/flask/kontor/models.py index 7150364..eba153c 100644 --- a/flask/kontor/models.py +++ b/flask/kontor/models.py @@ -1,66 +1,49 @@ -""" -This modules declares the model for Kontor users. -""" -from pymodm import MongoModel, fields -from pymongo.write_concern import WriteConcern -from flask_login import UserMixin -from werkzeug.security import generate_password_hash, check_password_hash -from kontor import _LOGIN_MANAGER_ +from kontor.extensions import db, ma +from sqlalchemy.sql import func -class User(UserMixin, MongoModel): - """ - This class represents an user for the Kontor application. - """ - email = fields.EmailField() - username = fields.CharField(max_length=60) - first_name = fields.CharField(max_length=60) - last_name = fields.CharField(max_length=60) - password_hash = fields.CharField(max_length=128) - is_admin = fields.BooleanField(default=False) +class Publisher(db.Model): + # __table__ = db.metadata.tables["publisher"] + __tablename__ = "publisher" + __table_args__ = {'extend_existing': True} + id = db.Column(db.String, primary_key=True) + created_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_modified_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + version = db.Column(db.Integer) + name = db.Column(db.String) + comics = db.relationship('Comic', back_populates='publisher', lazy='dynamic') - @property - def password(self): - """ - Prevent pasword from being accessed - """ - raise AttributeError('password is not a readable attribute.') - - @password.setter - def password(self, password): - """ - Set password to a hashed password - """ - self.password_hash = generate_password_hash(password) - - def verify_password(self, password): - """ - Check if hashed password matches actual password - """ - return check_password_hash(self.password_hash, password) - - def get_id(self): - """ - Get username as id - :return: - """ - return self.username - - def __repr__(self): - return ''.format(self.username) +class PublisherSchema(ma.SQLAlchemySchema): class Meta: - """Sets the connection and connections details.""" - connection_alias = 'kontor' - write_concern = WriteConcern(j=True) + model = Publisher + + comics = ma.List(ma.HyperlinkRelated("comics_api.index")) -# Set up user_loader -@_LOGIN_MANAGER_.user_loader -def load_user(user_name): - """ - Get list of users from database - :param user_name: - :return: - """ - return User.objects.get({'username': user_name}) +publisher_schema = PublisherSchema() +publishers_schema = PublisherSchema(many=True) + + +class Comic(db.Model): + # __table__ = db.metadata.tables["comic"] + __tablename__ = "comic" + __table_args__ = {'extend_existing': True} + id = db.Column(db.String, primary_key=True) + created_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_modified_date = db.Column(db.DateTime(timezone=True), server_default=func.now()) + version = db.Column(db.Integer) + title = db.Column(db.String) + publisher_id = db.Column(db.String, db.ForeignKey("publisher.id")) + publisher = db.relationship("Publisher", back_populates="comics") + + +class ComicSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = Comic + include_fk = True + publisher = ma.HyperlinkRelated("comics_api.publisher_detail") + + +comic_schema = ComicSchema() +comics_schema = ComicSchema(many=True) diff --git a/flask/kontor/office/__init__.py b/flask/kontor/office/__init__.py deleted file mode 100644 index d830d41..0000000 --- a/flask/kontor/office/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Define routing rules for office related information -""" diff --git a/flask/kontor/static/css/style.css b/flask/kontor/static/css/style.css deleted file mode 100644 index 35fe053..0000000 --- a/flask/kontor/static/css/style.css +++ /dev/null @@ -1,126 +0,0 @@ -/* app/static/css/style.css */ - -body, html { - width: 100%; - height: 100%; -} - -body, h1, h2, h3 { - font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif; - font-weight: 700; -} - -a, .navbar-default .navbar-brand, .navbar-default .navbar-nav>li>a { - color: #aec251; -} - -a:hover, .navbar-default .navbar-brand:hover, .navbar-default .navbar-nav>li>a:hover { - color: #687430; -} - -footer { - padding: 50px 0; - background-color: #f8f8f8; -} - -p.copyright { - margin: 15px 0 0; -} - -.alert-info { - width: 50%; - margin: auto; - color: #687430; - background-color: #e6ecca; - border-color: #aec251; -} - -.btn-default { - border-color: #aec251; - color: #aec251; -} - -.btn-default:hover { - background-color: #aec251; -} - -.center { - margin: auto; - width: 70%; - padding: 10px; -} - -.content-section { - padding: 50px 0; - border-top: 1px solid #e7e7e7; -} - -.footer, .push { - clear: both; - height: 4em; -} - -.intro-divider { - width: 400px; - border-top: 1px solid #f8f8f8; - border-bottom: 1px solid rgba(0,0,0,0.2); -} - -.intro-header { - padding-top: 50px; - padding-bottom: 50px; - text-align: center; - color: #f8f8f8; - background: url(../img/intro-bg.jpg) no-repeat center center; - background-size: cover; - height: 100%; -} - -.intro-message { - position: relative; - padding-top: 20%; - padding-bottom: 20%; -} - -.intro-message > h1 { - margin: 0; - text-shadow: 2px 2px 3px rgba(0,0,0,0.6); - font-size: 5em; -} - -.intro-message > h3 { - text-shadow: 2px 2px 3px rgba(0,0,0,0.6); -} - -.lead { - font-size: 18px; - font-weight: 400; -} - -.topnav { - font-size: 14px; -} - -.wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0 auto -4em; -} - -.outer { - display: table; - position: absolute; - height: 70%; - width: 100%; -} - -.middle { - display: table-cell; - vertical-align: middle; -} - -.inner { - margin-left: auto; - margin-right: auto; -} diff --git a/flask/kontor/templates/admin/users/user.html b/flask/kontor/templates/admin/users/user.html deleted file mode 100644 index 50df2c4..0000000 --- a/flask/kontor/templates/admin/users/user.html +++ /dev/null @@ -1,21 +0,0 @@ - - -{% import "bootstrap/wtf.html" as wtf %} -{% extends "base.html" %} -{% block title %}Edit User{% endblock %} -{% block body %} -
-
-
-
-
-

Edit User

-
-
- {{ wtf.quick_form(form) }} -
-
-
-
-
-{% endblock %} diff --git a/flask/kontor/templates/admin/users/users.html b/flask/kontor/templates/admin/users/users.html deleted file mode 100644 index b23f67c..0000000 --- a/flask/kontor/templates/admin/users/users.html +++ /dev/null @@ -1,58 +0,0 @@ - - -{% import "bootstrap/utils.html" as utils %} -{% extends "base.html" %} -{% block title %}Users{% endblock %} -{% block body %} -
-
-
-
-
- {{ utils.flashed_messages() }} -
-

Users

- {% if users %} -
-
- - - - - - - - - - - {% for user in users %} - {% if user.is_admin %} - - - - - - - {% else %} - - - - - - - {% endif %} - {% endfor %} - -
Name Email Username Edit
Admin N/A N/A N/A
{{ user.first_name }} {{ user.last_name }} {{ user.email }} {{ user.username }} - - Edit - -
-
- {% endif %} -
-
-
-
-
-{% endblock %} diff --git a/flask/kontor/templates/auth/login.html b/flask/kontor/templates/auth/login.html deleted file mode 100644 index 8b70e01..0000000 --- a/flask/kontor/templates/auth/login.html +++ /dev/null @@ -1,18 +0,0 @@ - - -{% import "bootstrap/utils.html" as utils %} -{% import "bootstrap/wtf.html" as wtf %} -{% extends "base.html" %} -{% block title %}Login{% endblock %} -{% block body %} -
-
- {{ utils.flashed_messages() }} -
-
-

Login to your account

-
- {{ wtf.quick_form(form) }} -
-
-{% endblock %} diff --git a/flask/kontor/templates/auth/register.html b/flask/kontor/templates/auth/register.html deleted file mode 100644 index 55d4918..0000000 --- a/flask/kontor/templates/auth/register.html +++ /dev/null @@ -1,14 +0,0 @@ - - -{% import "bootstrap/wtf.html" as wtf %} -{% extends "base.html" %} -{% block title %}Register{% endblock %} -{% block body %} -
-
-

Register for an account

-
- {{ wtf.quick_form(form) }} -
-
-{% endblock %} diff --git a/flask/kontor/templates/base.html b/flask/kontor/templates/base.html index 80bdc27..0d7af2d 100644 --- a/flask/kontor/templates/base.html +++ b/flask/kontor/templates/base.html @@ -1,107 +1,68 @@ - - {{ title }} | Kontor - - - - - + + {% block title %} {% endblock %} - FlaskApp + - + + + diff --git a/go/templates/kontor/header.html b/go/templates/kontor/header.html new file mode 100644 index 0000000..bcf20aa --- /dev/null +++ b/go/templates/kontor/header.html @@ -0,0 +1,17 @@ + + + + + + + {{ .title }} + + + + + + + + + + diff --git a/go/templates/kontor/index.html b/go/templates/kontor/index.html new file mode 100644 index 0000000..4e23ba7 --- /dev/null +++ b/go/templates/kontor/index.html @@ -0,0 +1,17 @@ + +{{ define "kontor/index.html" }} + +{{ template "header.html" .}} + +{{ template "menu.html" . }} + +
+ {{ if .InfoMessage}} +

{{.InfoMessage}}

+ {{end}} +
+ + +{{ template "footer.html" .}} + +{{ end }} diff --git a/go/templates/kontor/login-successful.html b/go/templates/kontor/login-successful.html new file mode 100644 index 0000000..46e07fa --- /dev/null +++ b/go/templates/kontor/login-successful.html @@ -0,0 +1,12 @@ + + + +{{ template "header.html" .}} +{{ template "menu.html" . }} + +
+ You have successfully logged in. +
+ + +{{ template "footer.html" .}} diff --git a/go/templates/kontor/login.html b/go/templates/kontor/login.html new file mode 100644 index 0000000..83fce69 --- /dev/null +++ b/go/templates/kontor/login.html @@ -0,0 +1,35 @@ + + + +{{ template "header.html" .}} +{{ template "menu.html" . }} + +

Login

+ + +
+
+ + {{ if .ErrorTitle}} +

+ {{.ErrorTitle}}: {{.ErrorMessage}} +

+ {{end}} + +
+
+ + +
+
+ + +
+ +
+
+
+ + + +{{ template "footer.html" .}} diff --git a/go/templates/kontor/menu.html b/go/templates/kontor/menu.html new file mode 100644 index 0000000..b52a358 --- /dev/null +++ b/go/templates/kontor/menu.html @@ -0,0 +1,36 @@ + diff --git a/go/templates/kontor/user-detail.html b/go/templates/kontor/user-detail.html new file mode 100644 index 0000000..983a614 --- /dev/null +++ b/go/templates/kontor/user-detail.html @@ -0,0 +1,59 @@ +{{ define "kontor/user-detail.html" }} +{{ template "header.html" .}} +{{ template "admin-menu.html" .}} + + +
+ + {{ if .ErrorTitle}} +

+ {{.ErrorTitle}}: {{.ErrorMessage}} +

+ {{end}} +
+
+ + {{ if .payload.Username }} + + {{ else }} + + {{ end }} +
+
+
+ + {{ if .payload.Firstname }} + + {{ else }} + + {{ end }} +
+
+ + {{ if .payload.Lastname }} + + {{ else }} + + {{ end }} +
+
+
+ + +
+
+ + +
+ + + {{ if eq .action "Save" }} + + {{ end }} +
+
+{{ template "footer.html" .}} +{{end}} diff --git a/go/templates/kontor/users.html b/go/templates/kontor/users.html new file mode 100644 index 0000000..8aef51c --- /dev/null +++ b/go/templates/kontor/users.html @@ -0,0 +1,38 @@ +{{ define "kontor/users.html" }} +{{ template "header.html" .}} +{{ template "admin-menu.html" .}} + + +
+ + + + + + + + + {{range .payload }} + + + + + {{ if .IsAdmin}} + + {{end}} + {{ if not .IsAdmin }} + + {{end}} + + {{end}} +
Registered users of Kontor
UsernameFirst NameLast NameAdministrator
{{.Username}}{{.Firstname}}{{.Lastname}}
+
+ Add entry +
+
+ +{{ template "footer.html" .}} +{{end}} diff --git a/go/templates/library/authors.html b/go/templates/library/authors.html new file mode 100644 index 0000000..781ec26 --- /dev/null +++ b/go/templates/library/authors.html @@ -0,0 +1,25 @@ + +{{ define "library/authors.html" }} +{{ template "header.html" .}} +{{ template "library/menu.html" .}} + +
+ + + + {{range .payload }} + + {{end}} +
Liste der Autoren
Name
{{.Name}}
+
+ Add entry +
+
+ + +{{ template "footer.html" .}} +{{ end }} diff --git a/go/templates/library/books.html b/go/templates/library/books.html new file mode 100644 index 0000000..456cb82 --- /dev/null +++ b/go/templates/library/books.html @@ -0,0 +1,25 @@ + +{{ define "library/books.html" }} +{{ template "header.html" .}} +{{ template "library/menu.html" . }} + +
+ + + + {{range .payload }} + + {{end}} +
Liste der Bücher
Title
{{.Title}}
+
+ Add entry +
+
+ +{{ template "footer.html" .}} + +{{ end }} diff --git a/go/templates/library/menu.html b/go/templates/library/menu.html new file mode 100644 index 0000000..2e6257f --- /dev/null +++ b/go/templates/library/menu.html @@ -0,0 +1,38 @@ +{{ define "library/menu.html" }} + +{{ end }} diff --git a/go/templates/library/publisher.html b/go/templates/library/publisher.html new file mode 100644 index 0000000..c0dae5e --- /dev/null +++ b/go/templates/library/publisher.html @@ -0,0 +1,32 @@ +{{ define "library/publisher.html" }} +{{ template "header.html" .}} +{{ template "library/menu.html" .}} + +
+ {{ if .ErrorTitle}} +

+ {{.ErrorTitle}}: {{.ErrorMessage}} +

+ {{end}} +
+
+ + {{ if .payload.Name }} + + {{ else }} + + {{ end }} +
+ + + {{ if eq .action "Save" }} + + {{ end }} +
+
+{{ template "footer.html" .}} +{{ end }} diff --git a/go/templates/library/publishers.html b/go/templates/library/publishers.html new file mode 100644 index 0000000..a166167 --- /dev/null +++ b/go/templates/library/publishers.html @@ -0,0 +1,24 @@ + +{{ define "library/publishers.html" }} +{{ template "header.html" .}} +{{ template "library/menu.html" .}} + +
+ + + + {{range .payload }} + + {{end}} +
Liste der Verlage
Name
{{.Name}}
+
+ Add entry +
+
+ +{{ template "footer.html" .}} +{{end}} diff --git a/go/templates/office/index.html b/go/templates/office/index.html new file mode 100644 index 0000000..3932a29 --- /dev/null +++ b/go/templates/office/index.html @@ -0,0 +1,10 @@ + +{{ define "office/index.html" }} +{{ template "header.html" .}} +{{ template "office/menu.html" . }} + +

Home Office

+ +{{ template "footer.html" .}} + +{{ end }} diff --git a/go/templates/office/menu.html b/go/templates/office/menu.html new file mode 100644 index 0000000..ddcd03d --- /dev/null +++ b/go/templates/office/menu.html @@ -0,0 +1,38 @@ +{{ define "office/menu.html" }} + +{{ end }} diff --git a/go/templates/tradingcards/index.html b/go/templates/tradingcards/index.html new file mode 100644 index 0000000..6911e17 --- /dev/null +++ b/go/templates/tradingcards/index.html @@ -0,0 +1,10 @@ + +{{ define "tradingcards/index.html" }} +{{ template "header.html" .}} +{{ template "tradingcards/menu.html" . }} + +

Trading Cards

+ +{{ template "footer.html" .}} + +{{ end }} diff --git a/go/templates/tradingcards/menu.html b/go/templates/tradingcards/menu.html new file mode 100644 index 0000000..524080c --- /dev/null +++ b/go/templates/tradingcards/menu.html @@ -0,0 +1,38 @@ +{{ define "tradingcards/menu.html" }} + +{{ end }} diff --git a/go/templates/tysc/index.html b/go/templates/tysc/index.html new file mode 100644 index 0000000..b1d135d --- /dev/null +++ b/go/templates/tysc/index.html @@ -0,0 +1,16 @@ +{{ define "tysc/index.html" }} +{{ template "header.html" .}} +{{ template "tysc/menu.html" . }} + +{{ template "footer.html" .}} +{{ end }} diff --git a/go/templates/tysc/menu.html b/go/templates/tysc/menu.html new file mode 100644 index 0000000..53a8e9e --- /dev/null +++ b/go/templates/tysc/menu.html @@ -0,0 +1,38 @@ +{{ define "tysc/menu.html" }} + +{{ end }} diff --git a/go/templates/tysc/sports.html b/go/templates/tysc/sports.html new file mode 100644 index 0000000..cf65f25 --- /dev/null +++ b/go/templates/tysc/sports.html @@ -0,0 +1,28 @@ +{{ define "tysc/sports.html" }} +{{ template "header.html" .}} +{{ template "tysc/menu.html" .}} + +
+ + + + {{range .payload }} + + {{end}} +
List of Amertican Sports
Name
{{.Name}}
+
+ Add entry +
+
+{{ template "footer.html" .}} +{{ end }} diff --git a/go/tysc-20041010-1819.sql b/go/tysc-20041010-1819.sql new file mode 100644 index 0000000..167c41d --- /dev/null +++ b/go/tysc-20041010-1819.sql @@ -0,0 +1,168 @@ +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE=NO_AUTO_VALUE_ON_ZERO */; +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `tysc`; +USE `tysc`; + +DROP TABLE IF EXISTS `angebote`; +CREATE TABLE `angebote` ( + `user_id` int(11) NOT NULL default '0', + `karte_id` int(11) NOT NULL default '0' +) TYPE=MyISAM; +INSERT INTO `angebote` (`user_id`,`karte_id`) VALUES (3,28),(3,30); + +DROP TABLE IF EXISTS `benutzer`; +CREATE TABLE `benutzer` ( + `ID` int(11) NOT NULL auto_increment, + `forename` varchar(40) default NULL, + `surname` varchar(40) default NULL, + `strasse` varchar(60) default NULL, + `plz` int(6) default NULL, + `ort` varchar(20) default NULL, + `username` varchar(20) default NULL, + `email` varchar(60) default NULL, + `password` varchar(32) default NULL, + `java` char(1) default NULL, + `language` int(11) NOT NULL default '0', + PRIMARY KEY (`ID`) +) TYPE=MyISAM; +INSERT INTO `benutzer` (`ID`,`forename`,`surname`,`strasse`,`plz`,`ort`,`username`,`email`,`password`,`java`,`language`) VALUES (1,'Thomas','Peetz','Reichweindamm 24',13627,'Berlin','gophard','thomas.peetz@snafu.de','t.log1n','N',1),(2,'Heiko','John','Johannastr.49',13581,'Berlin','John','healjo@hotmail.com','redskins','N',1),(3,'Thomas','Peetz','Reichweindamm 24',13627,'Berlin','peetz','gophard@snafu.de','peetz','N',1); + +DROP TABLE IF EXISTS `changelog`; +CREATE TABLE `changelog` ( + `datum` date default NULL, + `tablename` varchar(20) default NULL, + `id` int(11) NOT NULL default '0' +) TYPE=MyISAM; +INSERT INTO `changelog` (`datum`,`tablename`,`id`) VALUES ('2002-02-27','benutzer',1),('2002-02-28','benutzer',2),('2002-03-05','benutzer',3),('2002-03-05','angebote',0),('2002-03-05','angebote',0); + +DROP TABLE IF EXISTS `hersteller`; +CREATE TABLE `hersteller` ( + `ID` int(11) NOT NULL auto_increment, + `name` varchar(30) default NULL, + PRIMARY KEY (`ID`) +) TYPE=MyISAM; +INSERT INTO `hersteller` (`ID`,`name`) VALUES (1,'Pacific'),(2,'Fleer'),(3,'Bowman'),(6,'Topps'),(7,'Donruss'),(8,'Score'),(9,'Flair'); + +DROP TABLE IF EXISTS `inserts`; +CREATE TABLE `inserts` ( + `ID` int(11) NOT NULL auto_increment, + `hersteller_id` int(11) NOT NULL default '0', + `name` varchar(40) default NULL, + PRIMARY KEY (`ID`,`hersteller_id`) +) TYPE=MyISAM; +INSERT INTO `inserts` (`ID`,`hersteller_id`,`name`) VALUES (1,2,'Mystique Big Buzz'); + +DROP TABLE IF EXISTS `karte`; +CREATE TABLE `karte` ( + `ID` int(11) NOT NULL auto_increment, + `spieler_id` int(11) NOT NULL default '0', + `team_id` int(11) NOT NULL default '0', + `hersteller_id` int(11) NOT NULL default '0', + `serie_id` int(11) default NULL, + `parallel_id` int(11) default NULL, + `inserts_id` int(11) default NULL, + `rookie` char(1) default NULL, + `jahr` int(4) default NULL, + `nummer` int(11) default NULL, + PRIMARY KEY (`ID`) +) TYPE=MyISAM; +INSERT INTO `karte` (`ID`,`spieler_id`,`team_id`,`hersteller_id`,`serie_id`,`parallel_id`,`inserts_id`,`rookie`,`jahr`,`nummer`) VALUES (12,12,13,1,1,0,0,'N',2001,212),(1,1,2,1,1,0,0,'N',2001,185),(2,2,2,1,1,0,0,'N',2001,250),(3,3,8,1,1,0,0,'N',2001,103),(4,4,8,1,1,0,0,'N',2001,112),(5,5,6,1,1,0,0,'N',2001,37),(6,6,6,1,1,0,0,'N',2001,38),(7,7,6,1,1,0,0,'N',2001,31),(8,8,10,1,1,0,0,'N',2001,338),(9,9,10,1,1,0,0,'N',2001,335),(10,10,10,1,1,0,0,'N',2001,345),(11,11,13,1,1,0,0,'N',2001,213),(13,13,14,1,1,0,0,'N',2001,311),(14,14,14,1,1,0,0,'N',2001,312),(15,15,16,1,1,0,0,'N',2001,403),(16,16,16,1,1,0,0,'N',2001,397),(17,17,16,1,1,0,0,'N',2001,404),(18,18,18,1,1,0,0,'N',2001,116),(19,19,18,1,1,0,0,'N',2001,122),(20,20,18,1,1,0,0,'N',2001,117),(21,21,19,1,1,0,0,'N',2001,281),(22,22,19,1,1,0,0,'N',2001,321),(23,23,20,1,1,0,0,'N',2001,331),(24,24,20,1,1,0,0,'N',2001,324),(25,25,21,1,1,0,0,'N',2001,445),(26,26,27,1,1,0,0,'N',2001,28),(27,27,27,1,1,0,0,'N',2001,17),(28,28,27,1,1,0,0,'N',2001,23),(29,29,29,1,1,0,0,'N',2001,273); +INSERT INTO `karte` (`ID`,`spieler_id`,`team_id`,`hersteller_id`,`serie_id`,`parallel_id`,`inserts_id`,`rookie`,`jahr`,`nummer`) VALUES (30,30,31,1,1,0,0,'N',2001,380),(31,31,31,1,1,0,0,'N',2001,390),(32,32,31,1,1,0,0,'N',2001,381),(33,33,31,1,1,0,0,'N',2001,387),(34,34,31,1,1,0,0,'N',2001,386),(35,35,30,1,1,0,0,'N',2001,349),(36,36,30,1,1,0,0,'N',2001,350),(37,37,44,5,8,0,0,'N',1994,106); + +DROP TABLE IF EXISTS `language`; +CREATE TABLE `language` ( + `ID` int(11) NOT NULL auto_increment, + `name` varchar(15) default NULL, + PRIMARY KEY (`ID`) +) TYPE=MyISAM; + +DROP TABLE IF EXISTS `mannschaft`; +CREATE TABLE `mannschaft` ( + `ID` int(11) NOT NULL auto_increment, + `team_id` int(11) NOT NULL default '0', + `sportart_id` int(11) NOT NULL default '0', + PRIMARY KEY (`ID`) +) TYPE=MyISAM; + +DROP TABLE IF EXISTS `parallelset`; +CREATE TABLE `parallelset` ( + `ID` int(11) NOT NULL auto_increment, + `hersteller_id` int(11) NOT NULL default '0', + `name` varchar(40) default NULL, + PRIMARY KEY (`ID`,`hersteller_id`) +) TYPE=MyISAM; +INSERT INTO `parallelset` (`ID`,`hersteller_id`,`name`) VALUES (1,2,'Mystique Gold'),(2,1,'Pacific Copper'),(3,1,'Pacific Gold'); + +DROP TABLE IF EXISTS `position`; +CREATE TABLE `position` ( + `ID` int(11) NOT NULL auto_increment, + `sportart_id` int(11) NOT NULL default '0', + `name` varchar(20) default NULL, + PRIMARY KEY (`ID`) +) TYPE=MyISAM; +INSERT INTO `position` (`ID`,`sportart_id`,`name`) VALUES (1,1,'QB'),(2,1,'WR'),(3,1,'RB'),(4,1,'LB'),(5,1,'TE'),(6,1,'FB'),(7,1,'SS'),(8,1,'DE'),(9,1,'K'),(10,1,'P'),(11,1,'LG'),(12,1,'RG'),(13,1,'OF'),(14,1,'DB'),(15,1,'CB'),(16,2,'C'),(17,2,'1B'),(18,2,'2B'),(19,2,'3B'),(20,2,'SS'),(21,2,'LF'),(22,2,'CF'),(23,2,'RF'),(24,2,'DH'),(25,2,'P'); + +DROP TABLE IF EXISTS `serie`; +CREATE TABLE `serie` ( + `ID` int(11) NOT NULL auto_increment, + `hersteller_id` int(11) NOT NULL default '0', + `name` varchar(40) default NULL, + PRIMARY KEY (`ID`,`hersteller_id`) +) TYPE=MyISAM; +INSERT INTO `serie` (`ID`,`hersteller_id`,`name`) VALUES (1,1,'Pacific'),(2,2,'Fleer'),(3,3,'Bowman'),(4,4,'Leaf'),(5,2,'Ultra'),(6,2,'Mystique'),(7,1,'Finest Hour'),(8,5,'SP'),(9,5,'SPX'),(10,5,'SP Authentic'),(11,5,'Black Diamond'); + +DROP TABLE IF EXISTS `spiele`; +CREATE TABLE `spiele` ( + `datum` date default NULL, + `gast` int(11) NOT NULL default '0', + `gast_pkt` int(11) default NULL, + `heim` int(11) NOT NULL default '0', + `heim_pkt` int(11) default NULL +) TYPE=MyISAM; + +DROP TABLE IF EXISTS `spieler`; +CREATE TABLE `spieler` ( + `ID` int(11) NOT NULL auto_increment, + `name` varchar(40) default NULL, + PRIMARY KEY (`ID`) +) TYPE=MyISAM; +INSERT INTO `spieler` (`ID`,`name`) VALUES (1,'Pathon, Jerome'),(2,'Bruschi, Tedy'),(3,'Couch, Tim'),(4,'Shea, Aaron'),(5,'Lewis, Jamal'),(6,'Lewis, Jermaine'),(7,'Banks, Tony'),(8,'Fuamatu-Ma\'Afala, Chris'),(9,'Bettis, Jerome'),(10,'Stewart, Kordell'),(11,'Moon, Warren'),(12,'Lockett, Kevin'),(13,'Gannon, Rich'),(14,'Jett, James'),(15,'Strong, Mack'),(16,'Huard, Brock'),(17,'Watters, Ricky'),(18,'Aikman, Troy'),(19,'LaFleur, David'),(20,'Brazzell, Chris'),(21,'Dayne, Ron'),(22,'Brown, Na'),(23,'Small, Torrance'),(24,'Lewis, Chad'),(25,'Murrell, Adrian'),(26,'Smith, Maurice'),(27,'Chandler, Chris'),(28,'Kanell, Danny'),(29,'Williams, Ricky'),(30,'Garcia, Jeff'),(31,'Streets, Tai'),(32,'Garner, Charlie'),(33,'Rice, Jerry'),(34,'Owens, Terrell'),(35,'Bruce, Isaac'),(36,'Canidate, Trung'); + +DROP TABLE IF EXISTS `spielerposition`; +CREATE TABLE `spielerposition` ( + `spieler_id` int(11) NOT NULL default '0', + `sportart_id` int(11) NOT NULL default '0', + `position_id` int(11) NOT NULL default '0' +) TYPE=MyISAM; + +DROP TABLE IF EXISTS `sportart`; +CREATE TABLE `sportart` ( + `ID` int(11) NOT NULL auto_increment, + `name` varchar(30) default NULL, + PRIMARY KEY (`ID`) +) TYPE=MyISAM; +INSERT INTO `sportart` (`ID`,`name`) VALUES (1,'Football'),(2,'Baseball'),(3,'Basketball'),(4,'Hockey'); + +DROP TABLE IF EXISTS `suche`; +CREATE TABLE `suche` ( + `user_id` int(11) NOT NULL default '0', + `karte_id` int(11) NOT NULL default '0' +) TYPE=MyISAM; + +DROP TABLE IF EXISTS `team`; +CREATE TABLE `team` ( + `ID` int(11) NOT NULL auto_increment, + `sportart_id` int(11) NOT NULL default '0', + `name` varchar(40) default NULL, + `short` varchar(15) default NULL, + PRIMARY KEY (`ID`) +) TYPE=MyISAM; +INSERT INTO `team` (`ID`,`sportart_id`,`name`,`short`) VALUES (1,1,'Buffalo Bills','Bills'),(2,1,'Indianapolis Colts','Colts'),(3,1,'Miami Dolphins','Dolphins'),(4,1,'New England Patriots','Patriots'),(5,1,'New York Jets','Jets'),(6,1,'Baltimore Ravens','Ravens'),(7,1,'Cincinnati Bengals','Bengals'),(8,1,'Cleveland Browns','Browns'),(9,1,'Jacksonville Jaguars','Jaguars'),(10,1,'Pittsburgh Steelers','Steelers'),(11,1,'Tennessee Titans','Titans'),(12,1,'Denver Broncos','Broncos'),(13,1,'Kansas City Chiefs','Chiefs'),(14,1,'Oakland Raiders','Raiders'),(15,1,'San Diego Chargers','Chargers'),(16,1,'Seattle Seahawks','Seahawks'),(17,1,'Arizona Cardinals','Cardinals'),(18,1,'Dallas Cowboys','Cowboys'),(19,1,'New York Giants','Giants'),(20,1,'Philadelphia Eagles','Eagles'),(21,1,'Washington Redskins','Redskins'),(22,1,'Chicago Bears','Bears'),(23,1,'Detroit Lions','Lions'),(24,1,'Green Bay Packers','Packers'),(25,1,'Minnesota Vikings','Vikings'),(26,1,'Tampa Bay Buccaneers','Buccaneers'),(27,1,'Atlanta Falcons','Falcons'),(28,1,'Carolina Panthers','Panthers'); +INSERT INTO `team` (`ID`,`sportart_id`,`name`,`short`) VALUES (29,1,'New Orleans Saints','Saints'),(30,1,'St.Louis Rams','Rams'),(31,1,'San Francisco 49ers','49ers'),(32,2,'Baltimore Orioles','Orioles'),(33,2,'Boston Red Sox','Red Sox'),(34,2,'New York Yankees','Yankees'),(35,2,'Tampa Bay Devil Rays','Devil Rays'),(36,2,'Toronto Blue Jays','Blue Jays'),(37,2,'Chicago White Sox','White Sox'),(38,2,'Cleveland Indians','Indians'),(39,2,'Detroit Tigers','Tigers'),(40,2,'Kansas City Royals','Royals'),(41,2,'Minnesota Twins','Twins'),(42,2,'Anaheim Angels','Angels'),(43,2,'Oakland Athletics','Athletics'),(44,2,'Seattle Mariners','Mariners'),(45,2,'Texas Rangers','Rangers'),(46,2,'Atlanta Braves','Braves'),(47,2,'Florida Marlins','Marlins'),(48,2,'Montreal Expos','Expos'),(49,2,'New York Mets','Mets'),(50,2,'Philadelphia Phillies','Phillies'),(51,2,'Chicago Cubs','Cubs'),(52,2,'Cincinnati Reds','Reds'),(53,2,'Houston Astros','Astros'),(54,2,'Milwaukee Brewers','Brewers'),(55,2,'Pittsburgh Pirates','Pirates'),(56,2,'St.Louis Cardinals','Cardinals'); +INSERT INTO `team` (`ID`,`sportart_id`,`name`,`short`) VALUES (57,2,'Arizona Diamondbacks','Diamondbacks'),(58,2,'Colorado Rockies','Rockies'),(59,2,'Los Angeles Dodgers','Dodgers'),(60,2,'San Diego Padres','Padres'),(61,2,'San Francisco Giants','Giants'),(62,3,'Boston Celtics','Celtics'),(63,3,'Miami Heat','Heat'),(64,3,'New Jersey Nets','Mets'),(65,3,'New York Knicks','Knicks'),(66,3,'Orlando Magic','Magic'),(67,3,'Philadelphia 76ers','76ers'),(68,3,'Washington Wizards','Wizards'),(69,3,'Atlanta Hawks','Hawks'),(70,3,'Charlotte Hornets','Hornets'),(71,3,'Chicago Bulls','Bulls'),(72,3,'Cleveland Cavaliers','Cavaliers'),(73,3,'Detroit Pistons','Pistons'),(74,3,'Indiana Pacers','Pacers'),(75,3,'Milwaukee Bucks','Bucks'),(76,3,'Toronto Raptors','Raptors'),(77,3,'Dallas Mavericks','Mavericks'),(78,3,'Denver Nuggets','Nuggets'),(79,3,'Houston Rockets','Rockets'),(80,3,'Minnesota Timberwolves','Timberwolves'),(81,3,'San Antonio Spurs','Spurs'),(82,3,'Utah Jazz','Jazz'),(83,3,'Vancouver Grizzlies','Grizzlies'),(84,3,'Golden State Warriors','Warriors'); +INSERT INTO `team` (`ID`,`sportart_id`,`name`,`short`) VALUES (85,3,'Los Angeles Clippers','Clippers'),(86,3,'Los Angeles Lakers','Lakers'),(87,3,'Phoenix Suns','Suns'),(88,3,'Portland Trail Blazers','Blazers'),(89,3,'Sacramento Kings','Kings'),(90,3,'Seattle SuperSonics','SuperSonics'),(91,4,'Boston Bruins','Bruins'),(92,4,'Buffalo Sabres','Sabres'),(93,4,'Montreal Canadiens','Canadiens'),(94,4,'Ottawa Senators','Senators'),(95,4,'Toronto Maple Leafs','Maple Leafs'),(96,4,'New Jersey Devils','Devils'),(97,4,'New York Islander','Islander'),(98,4,'New York Rangers','Rangers'),(99,4,'Philadelphia Flyers','Flyers'),(100,4,'Pittsburgh Penguins','Penguins'),(101,4,'Atlanta Trashers','Trashers'),(102,4,'Carolina Hurricanes','Hurricanes'),(103,4,'Florida Panthers','Panthers'),(104,4,'Tampa Bay Lightnings','Lightnings'),(105,4,'Washington Capitals','Capitals'),(106,4,'Chicago Blackhawks','Blackhawks'),(107,4,'Columbo Blue Jackets','Blue Jackets'),(108,4,'Detroit Red Wings','Red Wings'),(109,4,'Nashville Predators','Predators'),(110,4,'St.Louis Blues','Blues'); +INSERT INTO `team` (`ID`,`sportart_id`,`name`,`short`) VALUES (111,4,'Calgary Flames','Flames'),(112,4,'Colorado Avalanche','Avalanche'),(113,4,'Edmonton Oilers','Oilers'),(114,4,'Minnesota Wild','Wild'),(115,4,'Vancouver Canucks','Canucks'),(116,4,'Anaheim Mighty Ducks','Mighty Ducks'),(117,4,'Dallas Stars','Stars'),(118,4,'Los Angeles Kings','Kings'),(119,4,'Phoenix Coyotes','Coyotes'),(120,4,'San Jose Sharks','Sharks'),(121,1,'Houston Texans','Texans'),(122,1,'Houston Oilers','Oilers'); +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; diff --git a/kotlin-quarkus/.dockerignore b/kotlin-quarkus/.dockerignore new file mode 100644 index 0000000..4361d2f --- /dev/null +++ b/kotlin-quarkus/.dockerignore @@ -0,0 +1,5 @@ +* +!build/*-runner +!build/*-runner.jar +!build/lib/* +!build/quarkus-app/* \ No newline at end of file diff --git a/kotlin-quarkus/.gitignore b/kotlin-quarkus/.gitignore new file mode 100644 index 0000000..285b6ba --- /dev/null +++ b/kotlin-quarkus/.gitignore @@ -0,0 +1,36 @@ +# Gradle +.gradle/ +build/ + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env diff --git a/kotlin-quarkus/README.md b/kotlin-quarkus/README.md new file mode 100644 index 0000000..08f2a76 --- /dev/null +++ b/kotlin-quarkus/README.md @@ -0,0 +1,78 @@ +# kontor Project + +This project uses Quarkus, the Supersonic Subatomic Java Framework. + +If you want to learn more about Quarkus, please visit its website: https://quarkus.io/ . + +## Running the application in dev mode + +You can run your application in dev mode that enables live coding using: +```shell script +./gradlew quarkusDev +``` + +> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/. + +## Packaging and running the application + +The application can be packaged using: +```shell script +./gradlew build +``` +It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `build/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar build/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: +```shell script +./gradlew build -Dquarkus.package.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar build/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: +```shell script +./gradlew build -Dquarkus.package.type=native +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: +```shell script +./gradlew build -Dquarkus.package.type=native -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./build/kontor-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult https://quarkus.io/guides/gradle-tooling. + +## Related Guides + +- JDBC Driver - H2 ([guide](https://quarkus.io/guides/datasource)): Connect to the H2 database via JDBC +- Hibernate ORM with Panache and Kotlin ([guide](https://quarkus.io/guides/hibernate-orm-panache-kotlin)): Define your persistent model in Hibernate ORM with Panache +- SmallRye OpenAPI ([guide](https://quarkus.io/guides/openapi-swaggerui)): Document your REST APIs with OpenAPI - comes with Swagger UI +- YAML Configuration ([guide](https://quarkus.io/guides/config#yaml)): Use YAML to configure your Quarkus application +- SmallRye Health ([guide](https://quarkus.io/guides/microprofile-health)): Monitor service health + +## Provided Code + +### YAML Config + +Configure your application with YAML + +[Related guide section...](https://quarkus.io/guides/config-reference#configuration-examples) + +The Quarkus application configuration is located in `src/main/resources/application.yml`. + +### RESTEasy Reactive + +Easily start your Reactive RESTful Web Services + +[Related guide section...](https://quarkus.io/guides/getting-started-reactive#reactive-jax-rs-resources) + +### SmallRye Health + +Monitor your application's health using SmallRye Health + +[Related guide section...](https://quarkus.io/guides/smallrye-health) diff --git a/kotlin-quarkus/build.gradle.kts b/kotlin-quarkus/build.gradle.kts new file mode 100644 index 0000000..0a6ac3a --- /dev/null +++ b/kotlin-quarkus/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + java + id("io.quarkus") +} + +repositories { + mavenCentral() + mavenLocal() +} + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + +dependencies { + implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation("io.quarkus:quarkus-resteasy-reactive-jackson") + implementation("io.quarkus:quarkus-jdbc-h2") + implementation("io.quarkus:quarkus-hibernate-orm-panache-kotlin") + implementation("io.quarkus:quarkus-smallrye-openapi") + implementation("io.quarkus:quarkus-config-yaml") + implementation("io.quarkus:quarkus-hibernate-reactive-panache-kotlin") + implementation("io.quarkus:quarkus-smallrye-health") + implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-resteasy-reactive") + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.rest-assured:rest-assured") +} + +group = "de.thpeetz.kontor" +version = "1.0.0-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +tasks.withType { + systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager") +} + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} diff --git a/kotlin-quarkus/gradle.properties b/kotlin-quarkus/gradle.properties new file mode 100644 index 0000000..1057e5c --- /dev/null +++ b/kotlin-quarkus/gradle.properties @@ -0,0 +1,6 @@ +#Gradle properties +quarkusPluginId=io.quarkus +quarkusPluginVersion=2.15.2.Final +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=2.15.2.Final \ No newline at end of file diff --git a/kotlin-quarkus/gradle/wrapper/gradle-wrapper.jar b/kotlin-quarkus/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..62d4c053550b91381bbd28b1afc82d634bf73a8a GIT binary patch literal 58910 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 literal 0 HcmV?d00001 diff --git a/kotlin-quarkus/gradle/wrapper/gradle-wrapper.properties b/kotlin-quarkus/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/kotlin-quarkus/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kotlin-quarkus/gradlew b/kotlin-quarkus/gradlew new file mode 100755 index 0000000..fbd7c51 --- /dev/null +++ b/kotlin-quarkus/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/kotlin-quarkus/gradlew.bat b/kotlin-quarkus/gradlew.bat new file mode 100644 index 0000000..a9f778a --- /dev/null +++ b/kotlin-quarkus/gradlew.bat @@ -0,0 +1,104 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin-quarkus/settings.gradle.kts b/kotlin-quarkus/settings.gradle.kts new file mode 100644 index 0000000..043f9fc --- /dev/null +++ b/kotlin-quarkus/settings.gradle.kts @@ -0,0 +1,13 @@ +pluginManagement { + val quarkusPluginVersion: String by settings + val quarkusPluginId: String by settings + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + } + plugins { + id(quarkusPluginId) version quarkusPluginVersion + } +} +rootProject.name="kontor" diff --git a/kotlin-quarkus/src/main/docker/Dockerfile.jvm b/kotlin-quarkus/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..669d901 --- /dev/null +++ b/kotlin-quarkus/src/main/docker/Dockerfile.jvm @@ -0,0 +1,94 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/kontor-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-jvm +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5005 +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-jvm +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-11:1.14 + +ENV LANGUAGE='en_US:en' + + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 build/quarkus-app/*.jar /deployments/ +COPY --chown=185 build/quarkus-app/app/ /deployments/app/ +COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 8080 +USER 185 +ENV AB_JOLOKIA_OFF="" +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + diff --git a/kotlin-quarkus/src/main/docker/Dockerfile.legacy-jar b/kotlin-quarkus/src/main/docker/Dockerfile.legacy-jar new file mode 100644 index 0000000..ab8ff1f --- /dev/null +++ b/kotlin-quarkus/src/main/docker/Dockerfile.legacy-jar @@ -0,0 +1,90 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=legacy-jar +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/kontor-legacy-jar . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-legacy-jar +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5005 +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-legacy-jar +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-11:1.14 + +ENV LANGUAGE='en_US:en' + + +COPY build/lib/* /deployments/lib/ +COPY build/*-runner.jar /deployments/quarkus-run.jar + +EXPOSE 8080 +USER 185 +ENV AB_JOLOKIA_OFF="" +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" diff --git a/kotlin-quarkus/src/main/docker/Dockerfile.native b/kotlin-quarkus/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..38e4141 --- /dev/null +++ b/kotlin-quarkus/src/main/docker/Dockerfile.native @@ -0,0 +1,27 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=native +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t quarkus/kontor . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.6 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/kotlin-quarkus/src/main/docker/Dockerfile.native-micro b/kotlin-quarkus/src/main/docker/Dockerfile.native-micro new file mode 100644 index 0000000..cce62e8 --- /dev/null +++ b/kotlin-quarkus/src/main/docker/Dockerfile.native-micro @@ -0,0 +1,30 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# It uses a micro base image, tuned for Quarkus native executables. +# It reduces the size of the resulting container image. +# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=native +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/kontor . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor +# +### +FROM quay.io/quarkus/quarkus-micro-image:1.0 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/kotlin-quarkus/src/main/resources/META-INF/resources/index.html b/kotlin-quarkus/src/main/resources/META-INF/resources/index.html new file mode 100644 index 0000000..f64aa10 --- /dev/null +++ b/kotlin-quarkus/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,289 @@ + + + + + kontor - 1.0.0-SNAPSHOT + + + +
+
+
+ + + + + quarkus_logo_horizontal_rgb_1280px_reverse + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+

You just made a Quarkus application.

+

This page is served by Quarkus.

+ Visit the Dev UI +

This page: src/main/resources/META-INF/resources/index.html

+

App configuration: src/main/resources/application.yml

+

Static assets: src/main/resources/META-INF/resources/

+

Code: src/main/java

+

Generated starter code:

+
    +
  • + RESTEasy Reactive Easily start your Reactive RESTful Web Services +
    @Path: /hello +
    Related guide +
  • + +
+
+
+

Selected extensions

+
    +
  • RESTEasy Reactive Jackson
  • +
  • JDBC Driver - H2 (guide)
  • +
  • Hibernate ORM with Panache and Kotlin (guide)
  • +
  • SmallRye OpenAPI (guide)
  • +
  • YAML Configuration (guide)
  • +
  • Hibernate Reactive Panache Kotlin
  • +
  • SmallRye Health (guide)
  • +
+
Documentation
+

Practical step-by-step guides to help you achieve a specific goal. Use them to help get your work + done.

+
Set up your IDE
+

Everyone has a favorite IDE they like to use to code. Learn how to configure yours to maximize your + Quarkus productivity.

+
+
+
+ + diff --git a/kotlin-quarkus/src/main/resources/application.yml b/kotlin-quarkus/src/main/resources/application.yml new file mode 100644 index 0000000..527a35f --- /dev/null +++ b/kotlin-quarkus/src/main/resources/application.yml @@ -0,0 +1,2 @@ +greeting: + message: "hello" diff --git a/kotlin-quarkus/src/native-test/java/de/thpeetz/kontor/GreetingResourceIT.java b/kotlin-quarkus/src/native-test/java/de/thpeetz/kontor/GreetingResourceIT.java new file mode 100644 index 0000000..ef71448 --- /dev/null +++ b/kotlin-quarkus/src/native-test/java/de/thpeetz/kontor/GreetingResourceIT.java @@ -0,0 +1,8 @@ +package de.thpeetz.kontor; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class GreetingResourceIT extends GreetingResourceTest { + // Execute the same tests but in packaged mode. +} diff --git a/kotlin-spring/.gitignore b/kotlin-spring/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/kotlin-spring/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/kotlin-spring/README.md b/kotlin-spring/README.md new file mode 100644 index 0000000..0473319 --- /dev/null +++ b/kotlin-spring/README.md @@ -0,0 +1,92 @@ +# Kontor Kotlin + + + +## Getting started + +To make it easy for you to get started with GitLab, here's a list of recommended next steps. + +Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! + +## Add your files + +- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files +- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: + +``` +cd existing_repo +git remote add origin https://gitlab.thpeetz.de/kontor/kontor-kotlin.git +git branch -M main +git push -uf origin main +``` + +## Integrate with your tools + +- [ ] [Set up project integrations](https://gitlab.thpeetz.de/kontor/kontor-kotlin/-/settings/integrations) + +## Collaborate with your team + +- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) +- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) +- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) +- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) +- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) + +## Test and Deploy + +Use the built-in continuous integration in GitLab. + +- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) +- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) +- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) +- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) +- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) + +*** + +# Editing this README + +When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. + +## Suggestions for a good README +Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. + +## Name +Choose a self-explaining name for your project. + +## Description +Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. + +## Badges +On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. + +## Visuals +Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. + +## Installation +Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. + +## Usage +Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. + +## Support +Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. + +## Roadmap +If you have ideas for releases in the future, it is a good idea to list them in the README. + +## Contributing +State if you are open to contributions and what your requirements are for accepting them. + +For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. + +You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. + +## Authors and acknowledgment +Show your appreciation to those who have contributed to the project. + +## License +For open source projects, say how it is licensed. + +## Project status +If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/kotlin-spring/build.gradle b/kotlin-spring/build.gradle new file mode 100644 index 0000000..c6df720 --- /dev/null +++ b/kotlin-spring/build.gradle @@ -0,0 +1,58 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'org.springframework.boot' version '2.7.7' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' + id 'org.jetbrains.kotlin.jvm' version '1.7.21' + id 'org.jetbrains.kotlin.plugin.spring' version '1.7.21' + id 'org.jetbrains.kotlin.plugin.jpa' version '1.7.21' + id 'org.jetbrains.kotlin.plugin.allopen' version '1.7.21' + id 'org.jetbrains.kotlin.kapt' version '1.7.21' +} + +group = 'de.thpeetz.kontor' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-mustache' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' + implementation 'org.jetbrains.kotlin:kotlin-reflect' + implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + kapt 'org.springframework.boot:spring-boot-configuration-processor' + runtimeOnly 'com.h2database:h2' + runtimeOnly 'org.springframework.boot:spring-boot-devtools' + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + exclude module: 'mockito-core' + } + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testImplementation 'com.ninja-squad:springmockk:3.1.1' + + +} + +tasks.withType(KotlinCompile) { + kotlinOptions { + freeCompilerArgs = ['-Xjsr305=strict'] + jvmTarget = '11' + } +} + +tasks.named('test') { + useJUnitPlatform() +} + +allOpen { + annotation("javax.persistence.Entity") + annotation("javax.persistence.Embeddable") + annotation("javax.persistence.MappedSuperclass") +} diff --git a/kotlin-spring/gradle/wrapper/gradle-wrapper.jar b/kotlin-spring/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/kotlin-spring/gradlew.bat b/kotlin-spring/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/kotlin-spring/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin-spring/settings.gradle b/kotlin-spring/settings.gradle new file mode 100644 index 0000000..185aa4e --- /dev/null +++ b/kotlin-spring/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'kontor' diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorApplication.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorApplication.kt new file mode 100644 index 0000000..279b7db --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorApplication.kt @@ -0,0 +1,16 @@ +package de.thpeetz.kontor + +import org.springframework.boot.Banner +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.runApplication + +@SpringBootApplication +@EnableConfigurationProperties(KontorProperties::class) +class KontorApplication + +fun main(args: Array) { + runApplication(*args) { + setBannerMode(Banner.Mode.OFF) + } +} diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorConfiguration.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorConfiguration.kt new file mode 100644 index 0000000..e54f2f9 --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorConfiguration.kt @@ -0,0 +1,79 @@ +package de.thpeetz.kontor + +import de.thpeetz.kontor.comics.* +import org.springframework.boot.ApplicationRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class KontorConfiguration { + @Bean + fun databaseInitializer(artistRepository: ArtistRepository, + publisherRepository: PublisherRepository, + comicRepository: ComicRepository, + issueRepository: IssueRepository + ) = ApplicationRunner { + + artistRepository.save(Artist("Turner, Michael")) + artistRepository.save(Artist("Bendis, Brian Michael")) + artistRepository.save(Artist("Land, Greg")) + artistRepository.save(Artist("Whedon, Joss")) + val marvel = publisherRepository.save(Publisher("Marvel")) + val aspen = publisherRepository.save(Publisher("Aspen")) + publisherRepository.save(Publisher("DC")) + val de = publisherRepository.save(Publisher("Dynamite Entertainment")) + val wildstorm = publisherRepository.save(Publisher("WildStorm")) + val bongo = publisherRepository.save(Publisher("Bongo")) + val image = publisherRepository.save(Publisher("Image")) + val darkHorse = publisherRepository.save(Publisher("Dark Horse Comics")) + val cliffhanger = publisherRepository.save(Publisher("Cliffhanger")) + comicRepository.save(Comic(title = "X-Men", publisher = marvel, currentOrder = false, completed = false)) + val redSonja = comicRepository.save(Comic(title = "Red Sonja", publisher = de, currentOrder = false, completed = false)) + val x23 = comicRepository.save(Comic(title = "X-23", publisher = marvel, currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Simpsons Comics", publisher = bongo, currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Futurama Comics", publisher = bongo, currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Bomb Queen III: The Good, The Bad and The Lovely", publisher = image, currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Bomb Queen IV: Suicide Bomber", publisher = image, currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Gen13", publisher = wildstorm, currentOrder = false, completed = false)) + val bombqueen = comicRepository.save(Comic(title = "Bomb Queen II: Queen of Hearts", publisher = image, currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Iron & The Maiden",publisher = aspen,currentOrder = false,completed = false)) + comicRepository.save(Comic(title = "Fathom",publisher = aspen,currentOrder = false,completed = false)) + comicRepository.save(Comic(title = "Soulfire",publisher = aspen,currentOrder = false,completed = false)) + comicRepository.save(Comic(title = "Star Wars: Rebellion",publisher = darkHorse,currentOrder = false,completed = false)) + comicRepository.save(Comic(title = "Star Wars: Rebellion",publisher = darkHorse,currentOrder = false,completed = false)) + comicRepository.save(Comic(title = "Star Wars: Knights of the Old Republic",publisher = darkHorse,currentOrder = false,completed = false)) + comicRepository.save(Comic(title = "Star Wars: Legacy",publisher = darkHorse,currentOrder = false,completed = false)) + comicRepository.save(Comic(title = "Star Wars: Dark Times", publisher = darkHorse, currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Samurai: Heaven and Earth", publisher = darkHorse, currentOrder = false, completed = false)) + val battlpope = comicRepository.save(Comic(title = "Battle Pope", publisher = image,currentOrder = false, completed = false)) + comicRepository.save(Comic(title = "Danger Girl", publisher = cliffhanger, currentOrder = false, completed = false)) + val marville = comicRepository.save(Comic(title = "Marville", publisher = marvel, currentOrder = false, completed = false)) + issueRepository.save(Issue(number = "0", comic = redSonja, gelesen = true)) + issueRepository.save(Issue(number = "1", comic = redSonja, gelesen = true)) + issueRepository.save(Issue(number = "2", comic = redSonja, gelesen = true)) + issueRepository.save(Issue(number = "1", comic = x23, gelesen = false)) + issueRepository.save(Issue(number = "1", comic = bombqueen, gelesen = false)) + issueRepository.save(Issue(number = "2", comic = bombqueen, gelesen = false)) + issueRepository.save(Issue(number = "3", comic = bombqueen, gelesen = false)) + issueRepository.save(Issue(number = "4", comic = bombqueen, gelesen = false)) + issueRepository.save(Issue(number = "1", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "2", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "3", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "4", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "5", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "6", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "7", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "8", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "9", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "10", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "11", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "12", comic = battlpope, gelesen = false)) + issueRepository.save(Issue(number = "1", comic = marville, gelesen = false)) + issueRepository.save(Issue(number = "2", comic = marville, gelesen = false)) + issueRepository.save(Issue(number = "3", comic = marville, gelesen = false)) + issueRepository.save(Issue(number = "4", comic = marville, gelesen = false)) + issueRepository.save(Issue(number = "5", comic = marville, gelesen = false)) + issueRepository.save(Issue(number = "6", comic = marville, gelesen = false)) + issueRepository.save(Issue(number = "7", comic = marville, gelesen = false)) + } +} diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorController.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorController.kt new file mode 100644 index 0000000..3d639dd --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorController.kt @@ -0,0 +1,24 @@ +package de.thpeetz.kontor + +import de.thpeetz.kontor.KontorProperties +import de.thpeetz.kontor.Navigation +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.ui.set +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@Controller +@RequestMapping("/") +class KontorController(val properties: KontorProperties, val navigation: Navigation) { + @GetMapping("/") + fun blog(model: Model): String { + model["title"] = properties.title + model["title"] = "Kontor" + model["banner"] = properties.banner + model["version"] = properties.version + model["navigation"] = navigation.links() + model["login"] = navigation.login() + return "kontor" + } +} \ No newline at end of file diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorProperties.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorProperties.kt new file mode 100644 index 0000000..92c77c7 --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/KontorProperties.kt @@ -0,0 +1,10 @@ +package de.thpeetz.kontor + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding + +@ConstructorBinding +@ConfigurationProperties("kontor") +data class KontorProperties(var title: String, var version: String = "unknown", val banner: Banner) { + data class Banner(val title: String? = null, val content: String) +} \ No newline at end of file diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/Navigation.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/Navigation.kt new file mode 100644 index 0000000..fb730af --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/Navigation.kt @@ -0,0 +1,24 @@ +package de.thpeetz.kontor + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class Navigation { + + @Bean + fun links(): Iterable = listOf( + Link("/comics", "Comics"), + Link("/library","Library"), + Link("/office", "HomeOffice"), + Link("/tradingcards", "Trading Cards"), + Link("/tysc","TradeYourSportsCards"), + Link("/user/login", ""), + Link("/admin/","Admin") + ) + + @Bean + fun login(): Iterable = listOf(Link("/user/login", "Login")) +} + +data class Link(val link: String, val title: String) diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/RequestLoggingFilter.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/RequestLoggingFilter.kt new file mode 100644 index 0000000..decb70f --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/RequestLoggingFilter.kt @@ -0,0 +1,25 @@ +package de.thpeetz.kontor + +import org.slf4j.LoggerFactory +import javax.servlet.Filter +import org.springframework.stereotype.Component +import javax.servlet.FilterChain +import javax.servlet.ServletRequest +import javax.servlet.ServletResponse + +@Component +class RequestLoggingFilter: Filter { + val loggerFactory = LoggerFactory.getLogger("Kontor Logger") + + override fun doFilter( + request: ServletRequest, + response: ServletResponse, + filterChain: FilterChain + ) { + val requestString = request.servletContext.contextPath.toString() + //val attributeNames = request.attributeNames + //attributeNames.toList().forEach { loggerFactory.info("Attribute: ${it.toString()}") } + loggerFactory.info("Logging request: $requestString") + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/ComicsApiController.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/ComicsApiController.kt new file mode 100644 index 0000000..d2e3b4e --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/ComicsApiController.kt @@ -0,0 +1,25 @@ +package de.thpeetz.kontor.comics + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("api/") +class ComicsApiController(val artistRepository: ArtistRepository, + val publisherRepository: PublisherRepository, + val comicRepository: ComicRepository, + val issueRepository: IssueRepository) { + + @GetMapping("/artist") + fun artistList() = artistRepository.findAll() + + @GetMapping("/publisher") + fun publisherList() = publisherRepository.findAll() + + @GetMapping("/comics") + fun comicsList() = comicRepository.findAll() + + @GetMapping("/issues") + fun issuesList() = issueRepository.findAll() +} diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/ComicsController.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/ComicsController.kt new file mode 100644 index 0000000..f339b04 --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/ComicsController.kt @@ -0,0 +1,29 @@ +package de.thpeetz.kontor.comics + +import de.thpeetz.kontor.KontorProperties +import de.thpeetz.kontor.Navigation +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.ui.set +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@Controller +@RequestMapping("/comics") +class ComicsController(private val properties: KontorProperties, + val navigation: Navigation, + val comicRepository: ComicRepository +) { + + @GetMapping("/") + fun blog(model: Model): String { + model["title"] = properties.title + model["title"] = "Comics" + model["banner"] = properties.banner + model["navigation"] = navigation.links() + model["comics"] = comicRepository.findAll().map { it } + return "comics" + } + + +} diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/Entities.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/Entities.kt new file mode 100644 index 0000000..3be00f5 --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/Entities.kt @@ -0,0 +1,36 @@ +package de.thpeetz.kontor.comics + +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.Id +import javax.persistence.ManyToOne + + +@Entity +class Artist( + val name: String, + @Id @GeneratedValue val id: Long? = null +) + +@Entity +class Publisher( + val name: String, + @Id @GeneratedValue val id: Long? = null +) + +@Entity +class Comic( + val title: String, + @ManyToOne val publisher: Publisher, + val currentOrder: Boolean, + val completed: Boolean, + @Id @GeneratedValue val id: Long? = null +) + +@Entity +class Issue( + val number: String, + @ManyToOne val comic: Comic, + val gelesen: Boolean, + @Id @GeneratedValue val id: Long? = null +) \ No newline at end of file diff --git a/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/Repositories.kt b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/Repositories.kt new file mode 100644 index 0000000..fe44b2e --- /dev/null +++ b/kotlin-spring/src/main/kotlin/de/thpeetz/kontor/comics/Repositories.kt @@ -0,0 +1,22 @@ +package de.thpeetz.kontor.comics + +import org.springframework.data.repository.CrudRepository + +interface ArtistRepository: CrudRepository { + + override fun findAll(): Iterable +} + +interface PublisherRepository: CrudRepository { + override fun findAll(): Iterable +} + +interface ComicRepository: CrudRepository { + + override fun findAll(): Iterable +} + +interface IssueRepository: CrudRepository { + + override fun findAll(): Iterable +} \ No newline at end of file diff --git a/kotlin-spring/src/main/resources/application.properties b/kotlin-spring/src/main/resources/application.properties new file mode 100644 index 0000000..6d27d3a --- /dev/null +++ b/kotlin-spring/src/main/resources/application.properties @@ -0,0 +1,6 @@ +spring.jpa.properties.hibernate.globally_quoted_identifiers=true +spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions = true +kontor.title=Kontor +kontor.version=1.0.0-SNAPSHOT +kontor.banner.title=Warning +kontor.banner.content=The blog will be down tomorrow. diff --git a/kotlin-spring/src/main/resources/templates/comics.mustache b/kotlin-spring/src/main/resources/templates/comics.mustache new file mode 100644 index 0000000..336d9f0 --- /dev/null +++ b/kotlin-spring/src/main/resources/templates/comics.mustache @@ -0,0 +1,23 @@ +{{> header}} + +{{> menu}} +

+ +
+ + + + {{#comics}} + + {{/comics}} +
List of Comics
Name
{{title}}
+
+ Add entry +
+
+ +{{> footer}} diff --git a/kotlin-spring/src/main/resources/templates/footer.mustache b/kotlin-spring/src/main/resources/templates/footer.mustache new file mode 100644 index 0000000..dde9847 --- /dev/null +++ b/kotlin-spring/src/main/resources/templates/footer.mustache @@ -0,0 +1,22 @@ +
+ + + + diff --git a/kotlin-spring/src/main/resources/templates/header.mustache b/kotlin-spring/src/main/resources/templates/header.mustache new file mode 100644 index 0000000..d794c9e --- /dev/null +++ b/kotlin-spring/src/main/resources/templates/header.mustache @@ -0,0 +1,15 @@ + + + + + {{title}} + + + + + + + + + + diff --git a/kotlin-spring/src/main/resources/templates/kontor.mustache b/kotlin-spring/src/main/resources/templates/kontor.mustache new file mode 100644 index 0000000..177306f --- /dev/null +++ b/kotlin-spring/src/main/resources/templates/kontor.mustache @@ -0,0 +1,18 @@ +{{> header}} + +{{> menu}} + +
+ {{#banner.title}} +
+ + +
+ {{/banner.title}} +
+ +{{> footer}} diff --git a/kotlin-spring/src/main/resources/templates/menu.mustache b/kotlin-spring/src/main/resources/templates/menu.mustache new file mode 100644 index 0000000..c4d9ba0 --- /dev/null +++ b/kotlin-spring/src/main/resources/templates/menu.mustache @@ -0,0 +1,23 @@ + diff --git a/kotlin-spring/src/test/resources/junit-platform.properties b/kotlin-spring/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..ab79697 --- /dev/null +++ b/kotlin-spring/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.testinstance.lifecycle.default = per_class diff --git a/wicket/.github/workflows/gradle.yml b/wicket/.github/workflows/gradle.yml new file mode 100644 index 0000000..58e1c59 --- /dev/null +++ b/wicket/.github/workflows/gradle.yml @@ -0,0 +1,26 @@ +# This workflow will build a Java project with Gradle +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle + +name: Java CI with Gradle + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build diff --git a/wicket/.gitignore b/wicket/.gitignore new file mode 100644 index 0000000..2e1f2c3 --- /dev/null +++ b/wicket/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +bin/ +.classpath +.project +.settings/ +.asciidoctorconfig.adoc diff --git a/wicket/.gitlab-ci.yml b/wicket/.gitlab-ci.yml new file mode 100644 index 0000000..514cefd --- /dev/null +++ b/wicket/.gitlab-ci.yml @@ -0,0 +1,36 @@ +variables: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + +before_script: + - source "/home/gitlab-runner/.sdkman/bin/sdkman-init.sh" + - sdk u java 11.0.12-open + +stages: + - build + - test + - analysis + - publish + +Build Application: + stage: build + script: ./gradlew build + +Create Documentation: + stage: build + script: ./gradlew asciidoctorPdf + +Test Application: + stage: test + script: ./gradlew check + +sonarqube-check: + stage: analysis + variables: + SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" # Defines the location of the analysis task cache + GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task + script: ./gradlew sonarqube + allow_failure: true + +Publish Artifacts: + stage: publish + script: ./gradlew publish diff --git a/wicket/README.md b/wicket/README.md new file mode 100644 index 0000000..ab12d07 --- /dev/null +++ b/wicket/README.md @@ -0,0 +1,4 @@ +![Java CI with Gradle](https://github.com/tpeetz/kontor-wicket/workflows/Java%20CI%20with%20Gradle/badge.svg) + +# kontor-wicket +Kontor Application with Apache Wicket diff --git a/wicket/build.gradle b/wicket/build.gradle new file mode 100644 index 0000000..0afbe9f --- /dev/null +++ b/wicket/build.gradle @@ -0,0 +1,61 @@ +plugins { + id 'war' + id 'jacoco-report-aggregation' + alias(versions.plugins.asciidoctorConvention) + alias(versions.plugins.javaConvention) + alias(versions.plugins.sonarqube) +} + +final BUILD_DATE = new Date().format('dd.MM.yyyy').toString() + +dependencies { + implementation versions.slf4j + implementation versions.commonscli + testImplementation versions.junit + implementation versions.bundles.logback + spotbugsPlugins 'com.h3xstream.findsecbugs:findsecbugs-plugin:1.12.0' + + implementation 'org.apache.wicket:wicket:9.9.1' + implementation 'org.apache.wicket:wicket-extensions:9.9.1' + testImplementation 'org.eclipse.jetty:jetty-webapp:9.4.44.v20210927' + testImplementation 'org.eclipse.jetty:jetty-jmx:9.4.44.v20210927' +} + +publishing { + publications { + application(MavenPublication) { + groupId = group + from components.java + } + } +} + +jacocoTestReport { + reports { + xml.enabled true + } +} + +test.finalizedBy jacocoTestReport + +spotbugs { + ignoreFailures = true +} + +sonarqube { + properties { + property "sonar.projectKey", "kontor_kontor-wicket_AYCAQ47WRCd9N673sSSD" + property "sonar.host.url", "https://sonar.thpeetz.de" + property "sonar.login", "7cf604ab1ebf48f7dc60d942c8196a132b228a6d" + property "sonar.qualitygate.wait", true + property "sonar.sourceEncoding", "UTF-8" + } +} + +tasks.named('sonarqube').configure { + dependsOn test +} + +wrapper { + gradleVersion = "7.5" +} diff --git a/wicket/gradle.properties b/wicket/gradle.properties new file mode 100644 index 0000000..e140eca --- /dev/null +++ b/wicket/gradle.properties @@ -0,0 +1,3 @@ +description='Anwendung Kontor mit Apache Wicket' +group=de.thpeetz +version=1.0.0-SNAPSHOT diff --git a/wicket/gradle/wrapper/gradle-wrapper.jar b/wicket/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 zcmb5WV{~QRw(p$^Dz@00IL3?^hro$gg*4VI_WAaTyVM5Foj~O|-84 z$;06hMwt*rV;^8iB z1~&0XWpYJmG?Ts^K9PC62H*`G}xom%S%yq|xvG~FIfP=9*f zZoDRJBm*Y0aId=qJ?7dyb)6)JGWGwe)MHeNSzhi)Ko6J<-m@v=a%NsP537lHe0R* z`If4$aaBA#S=w!2z&m>{lpTy^Lm^mg*3?M&7HFv}7K6x*cukLIGX;bQG|QWdn{%_6 zHnwBKr84#B7Z+AnBXa16a?or^R?+>$4`}{*a_>IhbjvyTtWkHw)|ay)ahWUd-qq$~ zMbh6roVsj;_qnC-R{G+Cy6bApVOinSU-;(DxUEl!i2)1EeQ9`hrfqj(nKI7?Z>Xur zoJz-a`PxkYit1HEbv|jy%~DO^13J-ut986EEG=66S}D3!L}Efp;Bez~7tNq{QsUMm zh9~(HYg1pA*=37C0}n4g&bFbQ+?-h-W}onYeE{q;cIy%eZK9wZjSwGvT+&Cgv z?~{9p(;bY_1+k|wkt_|N!@J~aoY@|U_RGoWX<;p{Nu*D*&_phw`8jYkMNpRTWx1H* z>J-Mi_!`M468#5Aix$$u1M@rJEIOc?k^QBc?T(#=n&*5eS#u*Y)?L8Ha$9wRWdH^3D4|Ps)Y?m0q~SiKiSfEkJ!=^`lJ(%W3o|CZ zSrZL-Xxc{OrmsQD&s~zPfNJOpSZUl%V8tdG%ei}lQkM+z@-4etFPR>GOH9+Y_F<3=~SXln9Kb-o~f>2a6Xz@AS3cn^;c_>lUwlK(n>z?A>NbC z`Ud8^aQy>wy=$)w;JZzA)_*Y$Z5hU=KAG&htLw1Uh00yE!|Nu{EZkch zY9O6x7Y??>!7pUNME*d!=R#s)ghr|R#41l!c?~=3CS8&zr6*aA7n9*)*PWBV2w+&I zpW1-9fr3j{VTcls1>ua}F*bbju_Xq%^v;-W~paSqlf zolj*dt`BBjHI)H9{zrkBo=B%>8}4jeBO~kWqO!~Thi!I1H(in=n^fS%nuL=X2+s!p}HfTU#NBGiwEBF^^tKU zbhhv+0dE-sbK$>J#t-J!B$TMgN@Wh5wTtK2BG}4BGfsZOoRUS#G8Cxv|6EI*n&Xxq zt{&OxCC+BNqz$9b0WM7_PyBJEVObHFh%%`~!@MNZlo*oXDCwDcFwT~Rls!aApL<)^ zbBftGKKBRhB!{?fX@l2_y~%ygNFfF(XJzHh#?`WlSL{1lKT*gJM zs>bd^H9NCxqxn(IOky5k-wALFowQr(gw%|`0991u#9jXQh?4l|l>pd6a&rx|v=fPJ z1mutj{YzpJ_gsClbWFk(G}bSlFi-6@mwoQh-XeD*j@~huW4(8ub%^I|azA)h2t#yG z7e_V_<4jlM3D(I+qX}yEtqj)cpzN*oCdYHa!nm%0t^wHm)EmFP*|FMw!tb@&`G-u~ zK)=Sf6z+BiTAI}}i{*_Ac$ffr*Wrv$F7_0gJkjx;@)XjYSh`RjAgrCck`x!zP>Ifu z&%he4P|S)H*(9oB4uvH67^0}I-_ye_!w)u3v2+EY>eD3#8QR24<;7?*hj8k~rS)~7 zSXs5ww)T(0eHSp$hEIBnW|Iun<_i`}VE0Nc$|-R}wlSIs5pV{g_Dar(Zz<4X3`W?K z6&CAIl4U(Qk-tTcK{|zYF6QG5ArrEB!;5s?tW7 zrE3hcFY&k)+)e{+YOJ0X2uDE_hd2{|m_dC}kgEKqiE9Q^A-+>2UonB+L@v3$9?AYw zVQv?X*pK;X4Ovc6Ev5Gbg{{Eu*7{N3#0@9oMI~}KnObQE#Y{&3mM4`w%wN+xrKYgD zB-ay0Q}m{QI;iY`s1Z^NqIkjrTlf`B)B#MajZ#9u41oRBC1oM1vq0i|F59> z#StM@bHt|#`2)cpl_rWB($DNJ3Lap}QM-+A$3pe}NyP(@+i1>o^fe-oxX#Bt`mcQc zb?pD4W%#ep|3%CHAYnr*^M6Czg>~L4?l16H1OozM{P*en298b+`i4$|w$|4AHbzqB zHpYUsHZET$Z0ztC;U+0*+amF!@PI%^oUIZy{`L{%O^i{Xk}X0&nl)n~tVEpcAJSJ} zverw15zP1P-O8h9nd!&hj$zuwjg?DoxYIw{jWM zW5_pj+wFy8Tsa9g<7Qa21WaV&;ejoYflRKcz?#fSH_)@*QVlN2l4(QNk| z4aPnv&mrS&0|6NHq05XQw$J^RR9T{3SOcMKCXIR1iSf+xJ0E_Wv?jEc*I#ZPzyJN2 zUG0UOXHl+PikM*&g$U@g+KbG-RY>uaIl&DEtw_Q=FYq?etc!;hEC_}UX{eyh%dw2V zTTSlap&5>PY{6I#(6`j-9`D&I#|YPP8a;(sOzgeKDWsLa!i-$frD>zr-oid!Hf&yS z!i^cr&7tN}OOGmX2)`8k?Tn!!4=tz~3hCTq_9CdiV!NIblUDxHh(FJ$zs)B2(t5@u z-`^RA1ShrLCkg0)OhfoM;4Z{&oZmAec$qV@ zGQ(7(!CBk<5;Ar%DLJ0p0!ResC#U<+3i<|vib1?{5gCebG7$F7URKZXuX-2WgF>YJ^i zMhHDBsh9PDU8dlZ$yJKtc6JA#y!y$57%sE>4Nt+wF1lfNIWyA`=hF=9Gj%sRwi@vd z%2eVV3y&dvAgyuJ=eNJR+*080dbO_t@BFJO<@&#yqTK&+xc|FRR;p;KVk@J3$S{p` zGaMj6isho#%m)?pOG^G0mzOAw0z?!AEMsv=0T>WWcE>??WS=fII$t$(^PDPMU(P>o z_*0s^W#|x)%tx8jIgZY~A2yG;US0m2ZOQt6yJqW@XNY_>_R7(Nxb8Ged6BdYW6{prd!|zuX$@Q2o6Ona8zzYC1u!+2!Y$Jc9a;wy+pXt}o6~Bu1oF1c zp7Y|SBTNi@=I(K%A60PMjM#sfH$y*c{xUgeSpi#HB`?|`!Tb&-qJ3;vxS!TIzuTZs-&%#bAkAyw9m4PJgvey zM5?up*b}eDEY+#@tKec)-c(#QF0P?MRlD1+7%Yk*jW;)`f;0a-ZJ6CQA?E%>i2Dt7T9?s|9ZF|KP4;CNWvaVKZ+Qeut;Jith_y{v*Ny6Co6!8MZx;Wgo z=qAi%&S;8J{iyD&>3CLCQdTX*$+Rx1AwA*D_J^0>suTgBMBb=*hefV+Ars#mmr+YsI3#!F@Xc1t4F-gB@6aoyT+5O(qMz*zG<9Qq*f0w^V!03rpr*-WLH}; zfM{xSPJeu6D(%8HU%0GEa%waFHE$G?FH^kMS-&I3)ycx|iv{T6Wx}9$$D&6{%1N_8 z_CLw)_9+O4&u94##vI9b-HHm_95m)fa??q07`DniVjAy`t7;)4NpeyAY(aAk(+T_O z1om+b5K2g_B&b2DCTK<>SE$Ode1DopAi)xaJjU>**AJK3hZrnhEQ9E`2=|HHe<^tv z63e(bn#fMWuz>4erc47}!J>U58%<&N<6AOAewyzNTqi7hJc|X{782&cM zHZYclNbBwU6673=!ClmxMfkC$(CykGR@10F!zN1Se83LR&a~$Ht&>~43OX22mt7tcZUpa;9@q}KDX3O&Ugp6< zLZLfIMO5;pTee1vNyVC$FGxzK2f>0Z-6hM82zKg44nWo|n}$Zk6&;5ry3`(JFEX$q zK&KivAe${e^5ZGc3a9hOt|!UOE&OocpVryE$Y4sPcs4rJ>>Kbi2_subQ9($2VN(3o zb~tEzMsHaBmBtaHAyES+d3A(qURgiskSSwUc9CfJ@99&MKp2sooSYZu+-0t0+L*!I zYagjOlPgx|lep9tiU%ts&McF6b0VE57%E0Ho%2oi?=Ks+5%aj#au^OBwNwhec zta6QAeQI^V!dF1C)>RHAmB`HnxyqWx?td@4sd15zPd*Fc9hpDXP23kbBenBxGeD$k z;%0VBQEJ-C)&dTAw_yW@k0u?IUk*NrkJ)(XEeI z9Y>6Vel>#s_v@=@0<{4A{pl=9cQ&Iah0iD0H`q)7NeCIRz8zx;! z^OO;1+IqoQNak&pV`qKW+K0^Hqp!~gSohcyS)?^P`JNZXw@gc6{A3OLZ?@1Uc^I2v z+X!^R*HCm3{7JPq{8*Tn>5;B|X7n4QQ0Bs79uTU%nbqOJh`nX(BVj!#f;#J+WZxx4 z_yM&1Y`2XzhfqkIMO7tB3raJKQS+H5F%o83bM+hxbQ zeeJm=Dvix$2j|b4?mDacb67v-1^lTp${z=jc1=j~QD>7c*@+1?py>%Kj%Ejp7Y-!? z8iYRUlGVrQPandAaxFfks53@2EC#0)%mrnmGRn&>=$H$S8q|kE_iWko4`^vCS2aWg z#!`RHUGyOt*k?bBYu3*j3u0gB#v(3tsije zgIuNNWNtrOkx@Pzs;A9un+2LX!zw+p3_NX^Sh09HZAf>m8l@O*rXy_82aWT$Q>iyy zqO7Of)D=wcSn!0+467&!Hl))eff=$aneB?R!YykdKW@k^_uR!+Q1tR)+IJb`-6=jj zymzA>Sv4>Z&g&WWu#|~GcP7qP&m*w-S$)7Xr;(duqCTe7p8H3k5>Y-n8438+%^9~K z3r^LIT_K{i7DgEJjIocw_6d0!<;wKT`X;&vv+&msmhAAnIe!OTdybPctzcEzBy88_ zWO{6i4YT%e4^WQZB)KHCvA(0tS zHu_Bg+6Ko%a9~$EjRB90`P(2~6uI@SFibxct{H#o&y40MdiXblu@VFXbhz>Nko;7R z70Ntmm-FePqhb%9gL+7U8@(ch|JfH5Fm)5${8|`Lef>LttM_iww6LW2X61ldBmG0z zax3y)njFe>j*T{i0s8D4=L>X^j0)({R5lMGVS#7(2C9@AxL&C-lZQx~czI7Iv+{%1 z2hEG>RzX4S8x3v#9sgGAnPzptM)g&LB}@%E>fy0vGSa(&q0ch|=ncKjNrK z`jA~jObJhrJ^ri|-)J^HUyeZXz~XkBp$VhcTEcTdc#a2EUOGVX?@mYx#Vy*!qO$Jv zQ4rgOJ~M*o-_Wptam=~krnmG*p^j!JAqoQ%+YsDFW7Cc9M%YPiBOrVcD^RY>m9Pd< zu}#9M?K{+;UIO!D9qOpq9yxUquQRmQNMo0pT`@$pVt=rMvyX)ph(-CCJLvUJy71DI zBk7oc7)-%ngdj~s@76Yse3L^gV0 z2==qfp&Q~L(+%RHP0n}+xH#k(hPRx(!AdBM$JCfJ5*C=K3ts>P?@@SZ_+{U2qFZb>4kZ{Go37{# zSQc+-dq*a-Vy4?taS&{Ht|MLRiS)Sn14JOONyXqPNnpq&2y~)6wEG0oNy>qvod$FF z`9o&?&6uZjhZ4_*5qWVrEfu(>_n2Xi2{@Gz9MZ8!YmjYvIMasE9yVQL10NBrTCczq zcTY1q^PF2l!Eraguf{+PtHV3=2A?Cu&NN&a8V(y;q(^_mFc6)%Yfn&X&~Pq zU1?qCj^LF(EQB1F`8NxNjyV%fde}dEa(Hx=r7$~ts2dzDwyi6ByBAIx$NllB4%K=O z$AHz1<2bTUb>(MCVPpK(E9wlLElo(aSd(Os)^Raum`d(g9Vd_+Bf&V;l=@mM=cC>) z)9b0enb)u_7V!!E_bl>u5nf&Rl|2r=2F3rHMdb7y9E}}F82^$Rf+P8%dKnOeKh1vs zhH^P*4Ydr^$)$h@4KVzxrHyy#cKmWEa9P5DJ|- zG;!Qi35Tp7XNj60=$!S6U#!(${6hyh7d4q=pF{`0t|N^|L^d8pD{O9@tF~W;#Je*P z&ah%W!KOIN;SyAEhAeTafJ4uEL`(RtnovM+cb(O#>xQnk?dzAjG^~4$dFn^<@-Na3 z395;wBnS{t*H;Jef2eE!2}u5Ns{AHj>WYZDgQJt8v%x?9{MXqJsGP|l%OiZqQ1aB! z%E=*Ig`(!tHh>}4_z5IMpg{49UvD*Pp9!pxt_gdAW%sIf3k6CTycOT1McPl=_#0?8 zVjz8Hj*Vy9c5-krd-{BQ{6Xy|P$6LJvMuX$* zA+@I_66_ET5l2&gk9n4$1M3LN8(yEViRx&mtd#LD}AqEs?RW=xKC(OCWH;~>(X6h!uDxXIPH06xh z*`F4cVlbDP`A)-fzf>MuScYsmq&1LUMGaQ3bRm6i7OsJ|%uhTDT zlvZA1M}nz*SalJWNT|`dBm1$xlaA>CCiQ zK`xD-RuEn>-`Z?M{1%@wewf#8?F|(@1e0+T4>nmlSRrNK5f)BJ2H*$q(H>zGD0>eL zQ!tl_Wk)k*e6v^m*{~A;@6+JGeWU-q9>?+L_#UNT%G?4&BnOgvm9@o7l?ov~XL+et zbGT)|G7)KAeqb=wHSPk+J1bdg7N3$vp(ekjI1D9V$G5Cj!=R2w=3*4!z*J-r-cyeb zd(i2KmX!|Lhey!snRw z?#$Gu%S^SQEKt&kep)up#j&9}e+3=JJBS(s>MH+|=R(`8xK{mmndWo_r`-w1#SeRD&YtAJ#GiVI*TkQZ}&aq<+bU2+coU3!jCI6E+Ad_xFW*ghnZ$q zAoF*i&3n1j#?B8x;kjSJD${1jdRB;)R*)Ao!9bd|C7{;iqDo|T&>KSh6*hCD!rwv= zyK#F@2+cv3=|S1Kef(E6Niv8kyLVLX&e=U;{0x{$tDfShqkjUME>f8d(5nzSkY6@! z^-0>DM)wa&%m#UF1F?zR`8Y3X#tA!*7Q$P3lZJ%*KNlrk_uaPkxw~ zxZ1qlE;Zo;nb@!SMazSjM>;34ROOoygo%SF);LL>rRonWwR>bmSd1XD^~sGSu$Gg# zFZ`|yKU0%!v07dz^v(tY%;So(e`o{ZYTX`hm;@b0%8|H>VW`*cr8R%3n|ehw2`(9B+V72`>SY}9^8oh$En80mZK9T4abVG*to;E z1_S6bgDOW?!Oy1LwYy=w3q~KKdbNtyH#d24PFjX)KYMY93{3-mPP-H>@M-_>N~DDu zENh~reh?JBAK=TFN-SfDfT^=+{w4ea2KNWXq2Y<;?(gf(FgVp8Zp-oEjKzB%2Iqj;48GmY3h=bcdYJ}~&4tS`Q1sb=^emaW$IC$|R+r-8V- zf0$gGE(CS_n4s>oicVk)MfvVg#I>iDvf~Ov8bk}sSxluG!6#^Z_zhB&U^`eIi1@j( z^CK$z^stBHtaDDHxn+R;3u+>Lil^}fj?7eaGB z&5nl^STqcaBxI@v>%zG|j))G(rVa4aY=B@^2{TFkW~YP!8!9TG#(-nOf^^X-%m9{Z zCC?iC`G-^RcBSCuk=Z`(FaUUe?hf3{0C>>$?Vs z`2Uud9M+T&KB6o4o9kvdi^Q=Bw!asPdxbe#W-Oaa#_NP(qpyF@bVxv5D5))srkU#m zj_KA+#7sqDn*Ipf!F5Byco4HOSd!Ui$l94|IbW%Ny(s1>f4|Mv^#NfB31N~kya9!k zWCGL-$0ZQztBate^fd>R!hXY_N9ZjYp3V~4_V z#eB)Kjr8yW=+oG)BuNdZG?jaZlw+l_ma8aET(s+-x+=F-t#Qoiuu1i`^x8Sj>b^U} zs^z<()YMFP7CmjUC@M=&lA5W7t&cxTlzJAts*%PBDAPuqcV5o7HEnqjif_7xGt)F% zGx2b4w{@!tE)$p=l3&?Bf#`+!-RLOleeRk3 z7#pF|w@6_sBmn1nECqdunmG^}pr5(ZJQVvAt$6p3H(16~;vO>?sTE`Y+mq5YP&PBo zvq!7#W$Gewy`;%6o^!Dtjz~x)T}Bdk*BS#=EY=ODD&B=V6TD2z^hj1m5^d6s)D*wk zu$z~D7QuZ2b?5`p)E8e2_L38v3WE{V`bVk;6fl#o2`) z99JsWhh?$oVRn@$S#)uK&8DL8>An0&S<%V8hnGD7Z^;Y(%6;^9!7kDQ5bjR_V+~wp zfx4m3z6CWmmZ<8gDGUyg3>t8wgJ5NkkiEm^(sedCicP^&3D%}6LtIUq>mXCAt{9eF zNXL$kGcoUTf_Lhm`t;hD-SE)m=iBnxRU(NyL}f6~1uH)`K!hmYZjLI%H}AmEF5RZt z06$wn63GHnApHXZZJ}s^s)j9(BM6e*7IBK6Bq(!)d~zR#rbxK9NVIlgquoMq z=eGZ9NR!SEqP6=9UQg#@!rtbbSBUM#ynF);zKX+|!Zm}*{H z+j=d?aZ2!?@EL7C~%B?6ouCKLnO$uWn;Y6Xz zX8dSwj732u(o*U3F$F=7xwxm>E-B+SVZH;O-4XPuPkLSt_?S0)lb7EEg)Mglk0#eS z9@jl(OnH4juMxY+*r03VDfPx_IM!Lmc(5hOI;`?d37f>jPP$?9jQQIQU@i4vuG6MagEoJrQ=RD7xt@8E;c zeGV*+Pt+t$@pt!|McETOE$9k=_C!70uhwRS9X#b%ZK z%q(TIUXSS^F0`4Cx?Rk07C6wI4!UVPeI~-fxY6`YH$kABdOuiRtl73MqG|~AzZ@iL&^s?24iS;RK_pdlWkhcF z@Wv-Om(Aealfg)D^adlXh9Nvf~Uf@y;g3Y)i(YP zEXDnb1V}1pJT5ZWyw=1i+0fni9yINurD=EqH^ciOwLUGi)C%Da)tyt=zq2P7pV5-G zR7!oq28-Fgn5pW|nlu^b!S1Z#r7!Wtr{5J5PQ>pd+2P7RSD?>(U7-|Y z7ZQ5lhYIl_IF<9?T9^IPK<(Hp;l5bl5tF9>X-zG14_7PfsA>6<$~A338iYRT{a@r_ zuXBaT=`T5x3=s&3=RYx6NgG>No4?5KFBVjE(swfcivcIpPQFx5l+O;fiGsOrl5teR z_Cm+;PW}O0Dwe_(4Z@XZ)O0W-v2X><&L*<~*q3dg;bQW3g7)a#3KiQP>+qj|qo*Hk z?57>f2?f@`=Fj^nkDKeRkN2d$Z@2eNKpHo}ksj-$`QKb6n?*$^*%Fb3_Kbf1(*W9K>{L$mud2WHJ=j0^=g30Xhg8$#g^?36`p1fm;;1@0Lrx+8t`?vN0ZorM zSW?rhjCE8$C|@p^sXdx z|NOHHg+fL;HIlqyLp~SSdIF`TnSHehNCU9t89yr@)FY<~hu+X`tjg(aSVae$wDG*C zq$nY(Y494R)hD!i1|IIyP*&PD_c2FPgeY)&mX1qujB1VHPG9`yFQpLFVQ0>EKS@Bp zAfP5`C(sWGLI?AC{XEjLKR4FVNw(4+9b?kba95ukgR1H?w<8F7)G+6&(zUhIE5Ef% z=fFkL3QKA~M@h{nzjRq!Y_t!%U66#L8!(2-GgFxkD1=JRRqk=n%G(yHKn%^&$dW>; zSjAcjETMz1%205se$iH_)ZCpfg_LwvnsZQAUCS#^FExp8O4CrJb6>JquNV@qPq~3A zZ<6dOU#6|8+fcgiA#~MDmcpIEaUO02L5#T$HV0$EMD94HT_eXLZ2Zi&(! z&5E>%&|FZ`)CN10tM%tLSPD*~r#--K(H-CZqIOb99_;m|D5wdgJ<1iOJz@h2Zkq?} z%8_KXb&hf=2Wza(Wgc;3v3TN*;HTU*q2?#z&tLn_U0Nt!y>Oo>+2T)He6%XuP;fgn z-G!#h$Y2`9>Jtf}hbVrm6D70|ERzLAU>3zoWhJmjWfgM^))T+2u$~5>HF9jQDkrXR z=IzX36)V75PrFjkQ%TO+iqKGCQ-DDXbaE;C#}!-CoWQx&v*vHfyI>$HNRbpvm<`O( zlx9NBWD6_e&J%Ous4yp~s6)Ghni!I6)0W;9(9$y1wWu`$gs<$9Mcf$L*piP zPR0Av*2%ul`W;?-1_-5Zy0~}?`e@Y5A&0H!^ApyVTT}BiOm4GeFo$_oPlDEyeGBbh z1h3q&Dx~GmUS|3@4V36&$2uO8!Yp&^pD7J5&TN{?xphf*-js1fP?B|`>p_K>lh{ij zP(?H%e}AIP?_i^f&Li=FDSQ`2_NWxL+BB=nQr=$ zHojMlXNGauvvwPU>ZLq!`bX-5F4jBJ&So{kE5+ms9UEYD{66!|k~3vsP+mE}x!>%P za98bAU0!h0&ka4EoiDvBM#CP#dRNdXJcb*(%=<(g+M@<)DZ!@v1V>;54En?igcHR2 zhubQMq}VSOK)onqHfczM7YA@s=9*ow;k;8)&?J3@0JiGcP! zP#00KZ1t)GyZeRJ=f0^gc+58lc4Qh*S7RqPIC6GugG1gXe$LIQMRCo8cHf^qXgAa2 z`}t>u2Cq1CbSEpLr~E=c7~=Qkc9-vLE%(v9N*&HF`(d~(0`iukl5aQ9u4rUvc8%m) zr2GwZN4!s;{SB87lJB;veebPmqE}tSpT>+`t?<457Q9iV$th%i__Z1kOMAswFldD6 ztbOvO337S5o#ZZgN2G99_AVqPv!?Gmt3pzgD+Hp3QPQ`9qJ(g=kjvD+fUSS3upJn! zqoG7acIKEFRX~S}3|{EWT$kdz#zrDlJU(rPkxjws_iyLKU8+v|*oS_W*-guAb&Pj1 z35Z`3z<&Jb@2Mwz=KXucNYdY#SNO$tcVFr9KdKm|%^e-TXzs6M`PBper%ajkrIyUe zp$vVxVs9*>Vp4_1NC~Zg)WOCPmOxI1V34QlG4!aSFOH{QqSVq1^1)- z0P!Z?tT&E-ll(pwf0?=F=yOzik=@nh1Clxr9}Vij89z)ePDSCYAqw?lVI?v?+&*zH z)p$CScFI8rrwId~`}9YWPFu0cW1Sf@vRELs&cbntRU6QfPK-SO*mqu|u~}8AJ!Q$z znzu}50O=YbjwKCuSVBs6&CZR#0FTu)3{}qJJYX(>QPr4$RqWiwX3NT~;>cLn*_&1H zaKpIW)JVJ>b{uo2oq>oQt3y=zJjb%fU@wLqM{SyaC6x2snMx-}ivfU<1- znu1Lh;i$3Tf$Kh5Uk))G!D1UhE8pvx&nO~w^fG)BC&L!_hQk%^p`Kp@F{cz>80W&T ziOK=Sq3fdRu*V0=S53rcIfWFazI}Twj63CG(jOB;$*b`*#B9uEnBM`hDk*EwSRdwP8?5T?xGUKs=5N83XsR*)a4|ijz|c{4tIU+4j^A5C<#5 z*$c_d=5ml~%pGxw#?*q9N7aRwPux5EyqHVkdJO=5J>84!X6P>DS8PTTz>7C#FO?k#edkntG+fJk8ZMn?pmJSO@`x-QHq;7^h6GEXLXo1TCNhH z8ZDH{*NLAjo3WM`xeb=X{((uv3H(8&r8fJJg_uSs_%hOH%JDD?hu*2NvWGYD+j)&` zz#_1%O1wF^o5ryt?O0n;`lHbzp0wQ?rcbW(F1+h7_EZZ9{>rePvLAPVZ_R|n@;b$;UchU=0j<6k8G9QuQf@76oiE*4 zXOLQ&n3$NR#p4<5NJMVC*S);5x2)eRbaAM%VxWu9ohlT;pGEk7;002enCbQ>2r-us z3#bpXP9g|mE`65VrN`+3mC)M(eMj~~eOf)do<@l+fMiTR)XO}422*1SL{wyY(%oMpBgJagtiDf zz>O6(m;};>Hi=t8o{DVC@YigqS(Qh+ix3Rwa9aliH}a}IlOCW1@?%h_bRbq-W{KHF z%Vo?-j@{Xi@=~Lz5uZP27==UGE15|g^0gzD|3x)SCEXrx`*MP^FDLl%pOi~~Il;dc z^hrwp9sYeT7iZ)-ajKy@{a`kr0-5*_!XfBpXwEcFGJ;%kV$0Nx;apKrur zJN2J~CAv{Zjj%FolyurtW8RaFmpn&zKJWL>(0;;+q(%(Hx!GMW4AcfP0YJ*Vz!F4g z!ZhMyj$BdXL@MlF%KeInmPCt~9&A!;cRw)W!Hi@0DY(GD_f?jeV{=s=cJ6e}JktJw zQORnxxj3mBxfrH=x{`_^Z1ddDh}L#V7i}$njUFRVwOX?qOTKjfPMBO4y(WiU<)epb zvB9L=%jW#*SL|Nd_G?E*_h1^M-$PG6Pc_&QqF0O-FIOpa4)PAEPsyvB)GKasmBoEt z?_Q2~QCYGH+hW31x-B=@5_AN870vY#KB~3a*&{I=f);3Kv7q4Q7s)0)gVYx2#Iz9g(F2;=+Iy4 z6KI^8GJ6D@%tpS^8boU}zpi=+(5GfIR)35PzrbuXeL1Y1N%JK7PG|^2k3qIqHfX;G zQ}~JZ-UWx|60P5?d1e;AHx!_;#PG%d=^X(AR%i`l0jSpYOpXoKFW~7ip7|xvN;2^? zsYC9fanpO7rO=V7+KXqVc;Q5z%Bj})xHVrgoR04sA2 zl~DAwv=!(()DvH*=lyhIlU^hBkA0$e*7&fJpB0|oB7)rqGK#5##2T`@_I^|O2x4GO z;xh6ROcV<9>?e0)MI(y++$-ksV;G;Xe`lh76T#Htuia+(UrIXrf9?

L(tZ$0BqX1>24?V$S+&kLZ`AodQ4_)P#Q3*4xg8}lMV-FLwC*cN$< zt65Rf%7z41u^i=P*qO8>JqXPrinQFapR7qHAtp~&RZ85$>ob|Js;GS^y;S{XnGiBc zGa4IGvDl?x%gY`vNhv8wgZnP#UYI-w*^4YCZnxkF85@ldepk$&$#3EAhrJY0U)lR{F6sM3SONV^+$;Zx8BD&Eku3K zKNLZyBni3)pGzU0;n(X@1fX8wYGKYMpLmCu{N5-}epPDxClPFK#A@02WM3!myN%bkF z|GJ4GZ}3sL{3{qXemy+#Uk{4>Kf8v11;f8I&c76+B&AQ8udd<8gU7+BeWC`akUU~U zgXoxie>MS@rBoyY8O8Tc&8id!w+_ooxcr!1?#rc$-|SBBtH6S?)1e#P#S?jFZ8u-Bs&k`yLqW|{j+%c#A4AQ>+tj$Y z^CZajspu$F%73E68Lw5q7IVREED9r1Ijsg#@DzH>wKseye>hjsk^{n0g?3+gs@7`i zHx+-!sjLx^fS;fY!ERBU+Q zVJ!e0hJH%P)z!y%1^ZyG0>PN@5W~SV%f>}c?$H8r;Sy-ui>aruVTY=bHe}$e zi&Q4&XK!qT7-XjCrDaufT@>ieQ&4G(SShUob0Q>Gznep9fR783jGuUynAqc6$pYX; z7*O@@JW>O6lKIk0G00xsm|=*UVTQBB`u1f=6wGAj%nHK_;Aqmfa!eAykDmi-@u%6~ z;*c!pS1@V8r@IX9j&rW&d*}wpNs96O2Ute>%yt{yv>k!6zfT6pru{F1M3P z2WN1JDYqoTB#(`kE{H676QOoX`cnqHl1Yaru)>8Ky~VU{)r#{&s86Vz5X)v15ULHA zAZDb{99+s~qI6;-dQ5DBjHJP@GYTwn;Dv&9kE<0R!d z8tf1oq$kO`_sV(NHOSbMwr=To4r^X$`sBW4$gWUov|WY?xccQJN}1DOL|GEaD_!@& z15p?Pj+>7d`@LvNIu9*^hPN)pwcv|akvYYq)ks%`G>!+!pW{-iXPZsRp8 z35LR;DhseQKWYSD`%gO&k$Dj6_6q#vjWA}rZcWtQr=Xn*)kJ9kacA=esi*I<)1>w^ zO_+E>QvjP)qiSZg9M|GNeLtO2D7xT6vsj`88sd!94j^AqxFLi}@w9!Y*?nwWARE0P znuI_7A-saQ+%?MFA$gttMV-NAR^#tjl_e{R$N8t2NbOlX373>e7Ox=l=;y#;M7asp zRCz*CLnrm$esvSb5{T<$6CjY zmZ(i{Rs_<#pWW>(HPaaYj`%YqBra=Ey3R21O7vUbzOkJJO?V`4-D*u4$Me0Bx$K(lYo`JO}gnC zx`V}a7m-hLU9Xvb@K2ymioF)vj12<*^oAqRuG_4u%(ah?+go%$kOpfb`T96P+L$4> zQ#S+sA%VbH&mD1k5Ak7^^dZoC>`1L%i>ZXmooA!%GI)b+$D&ziKrb)a=-ds9xk#~& z7)3iem6I|r5+ZrTRe_W861x8JpD`DDIYZNm{$baw+$)X^Jtjnl0xlBgdnNY}x%5za zkQ8E6T<^$sKBPtL4(1zi_Rd(tVth*3Xs!ulflX+70?gb&jRTnI8l+*Aj9{|d%qLZ+ z>~V9Z;)`8-lds*Zgs~z1?Fg?Po7|FDl(Ce<*c^2=lFQ~ahwh6rqSjtM5+$GT>3WZW zj;u~w9xwAhOc<kF}~`CJ68 z?(S5vNJa;kriPlim33{N5`C{9?NWhzsna_~^|K2k4xz1`xcui*LXL-1#Y}Hi9`Oo!zQ>x-kgAX4LrPz63uZ+?uG*84@PKq-KgQlMNRwz=6Yes) zY}>YN+qP}nwr$(CZQFjUOI=-6J$2^XGvC~EZ+vrqWaOXB$k?%Suf5k=4>AveC1aJ! ziaW4IS%F$_Babi)kA8Y&u4F7E%99OPtm=vzw$$ zEz#9rvn`Iot_z-r3MtV>k)YvErZ<^Oa${`2>MYYODSr6?QZu+be-~MBjwPGdMvGd!b!elsdi4% z`37W*8+OGulab8YM?`KjJ8e+jM(tqLKSS@=jimq3)Ea2EB%88L8CaM+aG7;27b?5` z4zuUWBr)f)k2o&xg{iZ$IQkJ+SK>lpq4GEacu~eOW4yNFLU!Kgc{w4&D$4ecm0f}~ zTTzquRW@`f0}|IILl`!1P+;69g^upiPA6F{)U8)muWHzexRenBU$E^9X-uIY2%&1w z_=#5*(nmxJ9zF%styBwivi)?#KMG96-H@hD-H_&EZiRNsfk7mjBq{L%!E;Sqn!mVX*}kXhwH6eh;b42eD!*~upVG@ z#smUqz$ICm!Y8wY53gJeS|Iuard0=;k5i5Z_hSIs6tr)R4n*r*rE`>38Pw&lkv{_r!jNN=;#?WbMj|l>cU(9trCq; z%nN~r^y7!kH^GPOf3R}?dDhO=v^3BeP5hF|%4GNQYBSwz;x({21i4OQY->1G=KFyu z&6d`f2tT9Yl_Z8YACZaJ#v#-(gcyeqXMhYGXb=t>)M@fFa8tHp2x;ODX=Ap@a5I=U z0G80^$N0G4=U(>W%mrrThl0DjyQ-_I>+1Tdd_AuB3qpYAqY54upwa3}owa|x5iQ^1 zEf|iTZxKNGRpI>34EwkIQ2zHDEZ=(J@lRaOH>F|2Z%V_t56Km$PUYu^xA5#5Uj4I4RGqHD56xT%H{+P8Ag>e_3pN$4m8n>i%OyJFPNWaEnJ4McUZPa1QmOh?t8~n& z&RulPCors8wUaqMHECG=IhB(-tU2XvHP6#NrLVyKG%Ee*mQ5Ps%wW?mcnriTVRc4J`2YVM>$ixSF2Xi+Wn(RUZnV?mJ?GRdw%lhZ+t&3s7g!~g{%m&i<6 z5{ib-<==DYG93I(yhyv4jp*y3#*WNuDUf6`vTM%c&hiayf(%=x@4$kJ!W4MtYcE#1 zHM?3xw63;L%x3drtd?jot!8u3qeqctceX3m;tWetK+>~q7Be$h>n6riK(5@ujLgRS zvOym)k+VAtyV^mF)$29Y`nw&ijdg~jYpkx%*^ z8dz`C*g=I?;clyi5|!27e2AuSa$&%UyR(J3W!A=ZgHF9OuKA34I-1U~pyD!KuRkjA zbkN!?MfQOeN>DUPBxoy5IX}@vw`EEB->q!)8fRl_mqUVuRu|C@KD-;yl=yKc=ZT0% zB$fMwcC|HE*0f8+PVlWHi>M`zfsA(NQFET?LrM^pPcw`cK+Mo0%8*x8@65=CS_^$cG{GZQ#xv($7J z??R$P)nPLodI;P!IC3eEYEHh7TV@opr#*)6A-;EU2XuogHvC;;k1aI8asq7ovoP!* z?x%UoPrZjj<&&aWpsbr>J$Er-7!E(BmOyEv!-mbGQGeJm-U2J>74>o5x`1l;)+P&~ z>}f^=Rx(ZQ2bm+YE0u=ZYrAV@apyt=v1wb?R@`i_g64YyAwcOUl=C!i>=Lzb$`tjv zOO-P#A+)t-JbbotGMT}arNhJmmGl-lyUpMn=2UacVZxmiG!s!6H39@~&uVokS zG=5qWhfW-WOI9g4!R$n7!|ViL!|v3G?GN6HR0Pt_L5*>D#FEj5wM1DScz4Jv@Sxnl zB@MPPmdI{(2D?;*wd>3#tjAirmUnQoZrVv`xM3hARuJksF(Q)wd4P$88fGYOT1p6U z`AHSN!`St}}UMBT9o7i|G`r$ zrB=s$qV3d6$W9@?L!pl0lf%)xs%1ko^=QY$ty-57=55PvP(^6E7cc zGJ*>m2=;fOj?F~yBf@K@9qwX0hA803Xw+b0m}+#a(>RyR8}*Y<4b+kpp|OS+!whP( zH`v{%s>jsQI9rd$*vm)EkwOm#W_-rLTHcZRek)>AtF+~<(did)*oR1|&~1|e36d-d zgtm5cv1O0oqgWC%Et@P4Vhm}Ndl(Y#C^MD03g#PH-TFy+7!Osv1z^UWS9@%JhswEq~6kSr2DITo59+; ze=ZC}i2Q?CJ~Iyu?vn|=9iKV>4j8KbxhE4&!@SQ^dVa-gK@YfS9xT(0kpW*EDjYUkoj! zE49{7H&E}k%5(>sM4uGY)Q*&3>{aitqdNnRJkbOmD5Mp5rv-hxzOn80QsG=HJ_atI-EaP69cacR)Uvh{G5dTpYG7d zbtmRMq@Sexey)||UpnZ?;g_KMZq4IDCy5}@u!5&B^-=6yyY{}e4Hh3ee!ZWtL*s?G zxG(A!<9o!CL+q?u_utltPMk+hn?N2@?}xU0KlYg?Jco{Yf@|mSGC<(Zj^yHCvhmyx z?OxOYoxbptDK()tsJ42VzXdINAMWL$0Gcw?G(g8TMB)Khw_|v9`_ql#pRd2i*?CZl z7k1b!jQB=9-V@h%;Cnl7EKi;Y^&NhU0mWEcj8B|3L30Ku#-9389Q+(Yet0r$F=+3p z6AKOMAIi|OHyzlHZtOm73}|ntKtFaXF2Fy|M!gOh^L4^62kGUoWS1i{9gsds_GWBc zLw|TaLP64z3z9?=R2|T6Xh2W4_F*$cq>MtXMOy&=IPIJ`;!Tw?PqvI2b*U1)25^<2 zU_ZPoxg_V0tngA0J+mm?3;OYw{i2Zb4x}NedZug!>EoN3DC{1i)Z{Z4m*(y{ov2%- zk(w>+scOO}MN!exSc`TN)!B=NUX`zThWO~M*ohqq;J2hx9h9}|s#?@eR!=F{QTrq~ zTcY|>azkCe$|Q0XFUdpFT=lTcyW##i;-e{}ORB4D?t@SfqGo_cS z->?^rh$<&n9DL!CF+h?LMZRi)qju!meugvxX*&jfD!^1XB3?E?HnwHP8$;uX{Rvp# zh|)hM>XDv$ZGg=$1{+_bA~u-vXqlw6NH=nkpyWE0u}LQjF-3NhATL@9rRxMnpO%f7 z)EhZf{PF|mKIMFxnC?*78(}{Y)}iztV12}_OXffJ;ta!fcFIVjdchyHxH=t%ci`Xd zX2AUB?%?poD6Zv*&BA!6c5S#|xn~DK01#XvjT!w!;&`lDXSJT4_j$}!qSPrb37vc{ z9^NfC%QvPu@vlxaZ;mIbn-VHA6miwi8qJ~V;pTZkKqqOii<1Cs}0i?uUIss;hM4dKq^1O35y?Yp=l4i zf{M!@QHH~rJ&X~8uATV><23zZUbs-J^3}$IvV_ANLS08>k`Td7aU_S1sLsfi*C-m1 z-e#S%UGs4E!;CeBT@9}aaI)qR-6NU@kvS#0r`g&UWg?fC7|b^_HyCE!8}nyh^~o@< zpm7PDFs9yxp+byMS(JWm$NeL?DNrMCNE!I^ko-*csB+dsf4GAq{=6sfyf4wb>?v1v zmb`F*bN1KUx-`ra1+TJ37bXNP%`-Fd`vVQFTwWpX@;s(%nDQa#oWhgk#mYlY*!d>( zE&!|ySF!mIyfING+#%RDY3IBH_fW$}6~1%!G`suHub1kP@&DoAd5~7J55;5_noPI6eLf{t;@9Kf<{aO0`1WNKd?<)C-|?C?)3s z>wEq@8=I$Wc~Mt$o;g++5qR+(6wt9GI~pyrDJ%c?gPZe)owvy^J2S=+M^ z&WhIE`g;;J^xQLVeCtf7b%Dg#Z2gq9hp_%g)-%_`y*zb; zn9`f`mUPN-Ts&fFo(aNTsXPA|J!TJ{0hZp0^;MYHLOcD=r_~~^ymS8KLCSeU3;^QzJNqS z5{5rEAv#l(X?bvwxpU;2%pQftF`YFgrD1jt2^~Mt^~G>T*}A$yZc@(k9orlCGv&|1 zWWvVgiJsCAtamuAYT~nzs?TQFt<1LSEx!@e0~@yd6$b5!Zm(FpBl;(Cn>2vF?k zOm#TTjFwd2D-CyA!mqR^?#Uwm{NBemP>(pHmM}9;;8`c&+_o3#E5m)JzfwN?(f-a4 zyd%xZc^oQx3XT?vcCqCX&Qrk~nu;fxs@JUoyVoi5fqpi&bUhQ2y!Ok2pzsFR(M(|U zw3E+kH_zmTRQ9dUMZWRE%Zakiwc+lgv7Z%|YO9YxAy`y28`Aw;WU6HXBgU7fl@dnt z-fFBV)}H-gqP!1;V@Je$WcbYre|dRdp{xt!7sL3Eoa%IA`5CAA%;Wq8PktwPdULo! z8!sB}Qt8#jH9Sh}QiUtEPZ6H0b*7qEKGJ%ITZ|vH)5Q^2m<7o3#Z>AKc%z7_u`rXA zqrCy{-{8;9>dfllLu$^M5L z-hXs))h*qz%~ActwkIA(qOVBZl2v4lwbM>9l70Y`+T*elINFqt#>OaVWoja8RMsep z6Or3f=oBnA3vDbn*+HNZP?8LsH2MY)x%c13@(XfuGR}R?Nu<|07{$+Lc3$Uv^I!MQ z>6qWgd-=aG2Y^24g4{Bw9ueOR)(9h`scImD=86dD+MnSN4$6 z^U*o_mE-6Rk~Dp!ANp#5RE9n*LG(Vg`1)g6!(XtDzsov$Dvz|Gv1WU68J$CkshQhS zCrc|cdkW~UK}5NeaWj^F4MSgFM+@fJd{|LLM)}_O<{rj z+?*Lm?owq?IzC%U%9EBga~h-cJbIu=#C}XuWN>OLrc%M@Gu~kFEYUi4EC6l#PR2JS zQUkGKrrS#6H7}2l0F@S11DP`@pih0WRkRJl#F;u{c&ZC{^$Z+_*lB)r)-bPgRFE;* zl)@hK4`tEP=P=il02x7-C7p%l=B`vkYjw?YhdJU9!P!jcmY$OtC^12w?vy3<<=tlY zUwHJ_0lgWN9vf>1%WACBD{UT)1qHQSE2%z|JHvP{#INr13jM}oYv_5#xsnv9`)UAO zuwgyV4YZ;O)eSc3(mka6=aRohi!HH@I#xq7kng?Acdg7S4vDJb6cI5fw?2z%3yR+| zU5v@Hm}vy;${cBp&@D=HQ9j7NcFaOYL zj-wV=eYF{|XTkFNM2uz&T8uH~;)^Zo!=KP)EVyH6s9l1~4m}N%XzPpduPg|h-&lL` zAXspR0YMOKd2yO)eMFFJ4?sQ&!`dF&!|niH*!^*Ml##o0M(0*uK9&yzekFi$+mP9s z>W9d%Jb)PtVi&-Ha!o~Iyh@KRuKpQ@)I~L*d`{O8!kRObjO7=n+Gp36fe!66neh+7 zW*l^0tTKjLLzr`x4`_8&on?mjW-PzheTNox8Hg7Nt@*SbE-%kP2hWYmHu#Fn@Q^J(SsPUz*|EgOoZ6byg3ew88UGdZ>9B2Tq=jF72ZaR=4u%1A6Vm{O#?@dD!(#tmR;eP(Fu z{$0O%=Vmua7=Gjr8nY%>ul?w=FJ76O2js&17W_iq2*tb!i{pt#`qZB#im9Rl>?t?0c zicIC}et_4d+CpVPx)i4~$u6N-QX3H77ez z?ZdvXifFk|*F8~L(W$OWM~r`pSk5}#F?j_5u$Obu9lDWIknO^AGu+Blk7!9Sb;NjS zncZA?qtASdNtzQ>z7N871IsPAk^CC?iIL}+{K|F@BuG2>qQ;_RUYV#>hHO(HUPpk@ z(bn~4|F_jiZi}Sad;_7`#4}EmD<1EiIxa48QjUuR?rC}^HRocq`OQPM@aHVKP9E#q zy%6bmHygCpIddPjE}q_DPC`VH_2m;Eey&ZH)E6xGeStOK7H)#+9y!%-Hm|QF6w#A( zIC0Yw%9j$s-#odxG~C*^MZ?M<+&WJ+@?B_QPUyTg9DJGtQN#NIC&-XddRsf3n^AL6 zT@P|H;PvN;ZpL0iv$bRb7|J{0o!Hq+S>_NrH4@coZtBJu#g8#CbR7|#?6uxi8d+$g z87apN>EciJZ`%Zv2**_uiET9Vk{pny&My;+WfGDw4EVL#B!Wiw&M|A8f1A@ z(yFQS6jfbH{b8Z-S7D2?Ixl`j0{+ZnpT=;KzVMLW{B$`N?Gw^Fl0H6lT61%T2AU**!sX0u?|I(yoy&Xveg7XBL&+>n6jd1##6d>TxE*Vj=8lWiG$4=u{1UbAa5QD>5_ z;Te^42v7K6Mmu4IWT6Rnm>oxrl~b<~^e3vbj-GCdHLIB_>59}Ya+~OF68NiH=?}2o zP(X7EN=quQn&)fK>M&kqF|<_*H`}c zk=+x)GU>{Af#vx&s?`UKUsz})g^Pc&?Ka@t5$n$bqf6{r1>#mWx6Ep>9|A}VmWRnowVo`OyCr^fHsf# zQjQ3Ttp7y#iQY8l`zEUW)(@gGQdt(~rkxlkefskT(t%@i8=|p1Y9Dc5bc+z#n$s13 zGJk|V0+&Ekh(F};PJzQKKo+FG@KV8a<$gmNSD;7rd_nRdc%?9)p!|B-@P~kxQG}~B zi|{0}@}zKC(rlFUYp*dO1RuvPC^DQOkX4<+EwvBAC{IZQdYxoq1Za!MW7%p7gGr=j zzWnAq%)^O2$eItftC#TTSArUyL$U54-O7e|)4_7%Q^2tZ^0-d&3J1}qCzR4dWX!)4 zzIEKjgnYgMus^>6uw4Jm8ga6>GBtMjpNRJ6CP~W=37~||gMo_p@GA@#-3)+cVYnU> zE5=Y4kzl+EbEh%dhQokB{gqNDqx%5*qBusWV%!iprn$S!;oN_6E3?0+umADVs4ako z?P+t?m?};gev9JXQ#Q&KBpzkHPde_CGu-y z<{}RRAx=xlv#mVi+Ibrgx~ujW$h{?zPfhz)Kp7kmYS&_|97b&H&1;J-mzrBWAvY} zh8-I8hl_RK2+nnf&}!W0P+>5?#?7>npshe<1~&l_xqKd0_>dl_^RMRq@-Myz&|TKZBj1=Q()) zF{dBjv5)h=&Z)Aevx}+i|7=R9rG^Di!sa)sZCl&ctX4&LScQ-kMncgO(9o6W6)yd< z@Rk!vkja*X_N3H=BavGoR0@u0<}m-7|2v!0+2h~S2Q&a=lTH91OJsvms2MT~ zY=c@LO5i`mLpBd(vh|)I&^A3TQLtr>w=zoyzTd=^f@TPu&+*2MtqE$Avf>l>}V|3-8Fp2hzo3y<)hr_|NO(&oSD z!vEjTWBxbKTiShVl-U{n*B3#)3a8$`{~Pk}J@elZ=>Pqp|MQ}jrGv7KrNcjW%TN_< zZz8kG{#}XoeWf7qY?D)L)8?Q-b@Na&>i=)(@uNo zr;cH98T3$Iau8Hn*@vXi{A@YehxDE2zX~o+RY`)6-X{8~hMpc#C`|8y> zU8Mnv5A0dNCf{Ims*|l-^ z(MRp{qoGohB34|ggDI*p!Aw|MFyJ|v+<+E3brfrI)|+l3W~CQLPbnF@G0)P~Ly!1TJLp}xh8uW`Q+RB-v`MRYZ9Gam3cM%{ zb4Cb*f)0deR~wtNb*8w-LlIF>kc7DAv>T0D(a3@l`k4TFnrO+g9XH7;nYOHxjc4lq zMmaW6qpgAgy)MckYMhl?>sq;-1E)-1llUneeA!ya9KM$)DaNGu57Z5aE>=VST$#vb zFo=uRHr$0M{-ha>h(D_boS4zId;3B|Tpqo|?B?Z@I?G(?&Iei+-{9L_A9=h=Qfn-U z1wIUnQe9!z%_j$F_{rf&`ZFSott09gY~qrf@g3O=Y>vzAnXCyL!@(BqWa)Zqt!#_k zfZHuwS52|&&)aK;CHq9V-t9qt0au{$#6c*R#e5n3rje0hic7c7m{kW$p(_`wB=Gw7 z4k`1Hi;Mc@yA7dp@r~?@rfw)TkjAW++|pkfOG}0N|2guek}j8Zen(!+@7?qt_7ndX zB=BG6WJ31#F3#Vk3=aQr8T)3`{=p9nBHlKzE0I@v`{vJ}h8pd6vby&VgFhzH|q;=aonunAXL6G2y(X^CtAhWr*jI zGjpY@raZDQkg*aMq}Ni6cRF z{oWv}5`nhSAv>usX}m^GHt`f(t8@zHc?K|y5Zi=4G*UG1Sza{$Dpj%X8 zzEXaKT5N6F5j4J|w#qlZP!zS7BT)9b+!ZSJdToqJts1c!)fwih4d31vfb{}W)EgcA zH2pZ^8_k$9+WD2n`6q5XbOy8>3pcYH9 z07eUB+p}YD@AH!}p!iKv><2QF-Y^&xx^PAc1F13A{nUeCDg&{hnix#FiO!fe(^&%Qcux!h znu*S!s$&nnkeotYsDthh1dq(iQrE|#f_=xVgfiiL&-5eAcC-> z5L0l|DVEM$#ulf{bj+Y~7iD)j<~O8CYM8GW)dQGq)!mck)FqoL^X zwNdZb3->hFrbHFm?hLvut-*uK?zXn3q1z|UX{RZ;-WiLoOjnle!xs+W0-8D)kjU#R z+S|A^HkRg$Ij%N4v~k`jyHffKaC~=wg=9)V5h=|kLQ@;^W!o2^K+xG&2n`XCd>OY5Ydi= zgHH=lgy++erK8&+YeTl7VNyVm9-GfONlSlVb3)V9NW5tT!cJ8d7X)!b-$fb!s76{t z@d=Vg-5K_sqHA@Zx-L_}wVnc@L@GL9_K~Zl(h5@AR#FAiKad8~KeWCo@mgXIQ#~u{ zgYFwNz}2b6Vu@CP0XoqJ+dm8px(5W5-Jpis97F`+KM)TuP*X8H@zwiVKDKGVp59pI zifNHZr|B+PG|7|Y<*tqap0CvG7tbR1R>jn70t1X`XJixiMVcHf%Ez*=xm1(CrTSDt z0cle!+{8*Ja&EOZ4@$qhBuKQ$U95Q%rc7tg$VRhk?3=pE&n+T3upZg^ZJc9~c2es% zh7>+|mrmA-p&v}|OtxqmHIBgUxL~^0+cpfkSK2mhh+4b=^F1Xgd2)}U*Yp+H?ls#z zrLxWg_hm}AfK2XYWr!rzW4g;+^^&bW%LmbtRai9f3PjU${r@n`JThy-cphbcwn)rq9{A$Ht`lmYKxOacy z6v2R(?gHhD5@&kB-Eg?4!hAoD7~(h>(R!s1c1Hx#s9vGPePUR|of32bS`J5U5w{F) z>0<^ktO2UHg<0{oxkdOQ;}coZDQph8p6ruj*_?uqURCMTac;>T#v+l1Tc~%^k-Vd@ zkc5y35jVNc49vZpZx;gG$h{%yslDI%Lqga1&&;mN{Ush1c7p>7e-(zp}6E7f-XmJb4nhk zb8zS+{IVbL$QVF8pf8}~kQ|dHJAEATmmnrb_wLG}-yHe>W|A&Y|;muy-d^t^<&)g5SJfaTH@P1%euONny=mxo+C z4N&w#biWY41r8k~468tvuYVh&XN&d#%QtIf9;iVXfWY)#j=l`&B~lqDT@28+Y!0E+MkfC}}H*#(WKKdJJq=O$vNYCb(ZG@p{fJgu;h z21oHQ(14?LeT>n5)s;uD@5&ohU!@wX8w*lB6i@GEH0pM>YTG+RAIWZD;4#F1&F%Jp zXZUml2sH0!lYJT?&sA!qwez6cXzJEd(1ZC~kT5kZSp7(@=H2$Azb_*W&6aA|9iwCL zdX7Q=42;@dspHDwYE?miGX#L^3xD&%BI&fN9^;`v4OjQXPBaBmOF1;#C)8XA(WFlH zycro;DS2?(G&6wkr6rqC>rqDv3nfGw3hmN_9Al>TgvmGsL8_hXx09};l9Ow@)F5@y z#VH5WigLDwZE4nh^7&@g{1FV^UZ%_LJ-s<{HN*2R$OPg@R~Z`c-ET*2}XB@9xvAjrK&hS=f|R8Gr9 zr|0TGOsI7RD+4+2{ZiwdVD@2zmg~g@^D--YL;6UYGSM8i$NbQr4!c7T9rg!8;TM0E zT#@?&S=t>GQm)*ua|?TLT2ktj#`|R<_*FAkOu2Pz$wEc%-=Y9V*$&dg+wIei3b*O8 z2|m$!jJG!J!ZGbbIa!(Af~oSyZV+~M1qGvelMzPNE_%5?c2>;MeeG2^N?JDKjFYCy z7SbPWH-$cWF9~fX%9~v99L!G(wi!PFp>rB!9xj7=Cv|F+7CsGNwY0Q_J%FID%C^CBZQfJ9K(HK%k31j~e#&?hQ zNuD6gRkVckU)v+53-fc} z7ZCzYN-5RG4H7;>>Hg?LU9&5_aua?A0)0dpew1#MMlu)LHe(M;OHjHIUl7|%%)YPo z0cBk;AOY00%Fe6heoN*$(b<)Cd#^8Iu;-2v@>cE-OB$icUF9EEoaC&q8z9}jMTT2I z8`9;jT%z0;dy4!8U;GW{i`)3!c6&oWY`J3669C!tM<5nQFFrFRglU8f)5Op$GtR-3 zn!+SPCw|04sv?%YZ(a7#L?vsdr7ss@WKAw&A*}-1S|9~cL%uA+E~>N6QklFE>8W|% zyX-qAUGTY1hQ-+um`2|&ji0cY*(qN!zp{YpDO-r>jPk*yuVSay<)cUt`t@&FPF_&$ zcHwu1(SQ`I-l8~vYyUxm@D1UEdFJ$f5Sw^HPH7b!9 zzYT3gKMF((N(v0#4f_jPfVZ=ApN^jQJe-X$`A?X+vWjLn_%31KXE*}5_}d8 zw_B1+a#6T1?>M{ronLbHIlEsMf93muJ7AH5h%;i99<~JX^;EAgEB1uHralD*!aJ@F zV2ruuFe9i2Q1C?^^kmVy921eb=tLDD43@-AgL^rQ3IO9%+vi_&R2^dpr}x{bCVPej z7G0-0o64uyWNtr*loIvslyo0%)KSDDKjfThe0hcqs)(C-MH1>bNGBDRTW~scy_{w} zp^aq8Qb!h9Lwielq%C1b8=?Z=&U)ST&PHbS)8Xzjh2DF?d{iAv)Eh)wsUnf>UtXN( zL7=$%YrZ#|^c{MYmhn!zV#t*(jdmYdCpwqpZ{v&L8KIuKn`@IIZfp!uo}c;7J57N` zAxyZ-uA4=Gzl~Ovycz%MW9ZL7N+nRo&1cfNn9(1H5eM;V_4Z_qVann7F>5f>%{rf= zPBZFaV@_Sobl?Fy&KXyzFDV*FIdhS5`Uc~S^Gjo)aiTHgn#<0C=9o-a-}@}xDor;D zZyZ|fvf;+=3MZd>SR1F^F`RJEZo+|MdyJYQAEauKu%WDol~ayrGU3zzbHKsnHKZ*z zFiwUkL@DZ>!*x05ql&EBq@_Vqv83&?@~q5?lVmffQZ+V-=qL+!u4Xs2Z2zdCQ3U7B&QR9_Iggy} z(om{Y9eU;IPe`+p1ifLx-XWh?wI)xU9ik+m#g&pGdB5Bi<`PR*?92lE0+TkRuXI)z z5LP!N2+tTc%cB6B1F-!fj#}>S!vnpgVU~3!*U1ej^)vjUH4s-bd^%B=ItQqDCGbrEzNQi(dJ`J}-U=2{7-d zK8k^Rlq2N#0G?9&1?HSle2vlkj^KWSBYTwx`2?9TU_DX#J+f+qLiZCqY1TXHFxXZqYMuD@RU$TgcnCC{_(vwZ-*uX)~go#%PK z@}2Km_5aQ~(<3cXeJN6|F8X_1@L%@xTzs}$_*E|a^_URF_qcF;Pfhoe?FTFwvjm1o z8onf@OY@jC2tVcMaZS;|T!Ks(wOgPpRzRnFS-^RZ4E!9dsnj9sFt609a|jJbb1Dt@ z<=Gal2jDEupxUSwWu6zp<<&RnAA;d&4gKVG0iu6g(DsST(4)z6R)zDpfaQ}v{5ARt zyhwvMtF%b-YazR5XLz+oh=mn;y-Mf2a8>7?2v8qX;19y?b>Z5laGHvzH;Nu9S`B8} zI)qN$GbXIQ1VL3lnof^6TS~rvPVg4V?Dl2Bb*K2z4E{5vy<(@@K_cN@U>R!>aUIRnb zL*)=787*cs#zb31zBC49x$`=fkQbMAef)L2$dR{)6BAz!t5U_B#1zZG`^neKSS22oJ#5B=gl%U=WeqL9REF2g zZnfCb0?quf?Ztj$VXvDSWoK`0L=Zxem2q}!XWLoT-kYMOx)!7fcgT35uC~0pySEme z`{wGWTkGr7>+Kb^n;W?BZH6ZP(9tQX%-7zF>vc2}LuWDI(9kh1G#7B99r4x6;_-V+k&c{nPUrR zAXJGRiMe~aup{0qzmLNjS_BC4cB#sXjckx{%_c&^xy{M61xEb>KW_AG5VFXUOjAG4 z^>Qlm9A#1N{4snY=(AmWzatb!ngqiqPbBZ7>Uhb3)dTkSGcL#&SH>iMO-IJBPua`u zo)LWZ>=NZLr758j{%(|uQuZ)pXq_4c!!>s|aDM9#`~1bzK3J1^^D#<2bNCccH7~-X}Ggi!pIIF>uFx%aPARGQsnC8ZQc8lrQ5o~smqOg>Ti^GNme94*w z)JZy{_{#$jxGQ&`M z!OMvZMHR>8*^>eS%o*6hJwn!l8VOOjZQJvh)@tnHVW&*GYPuxqXw}%M!(f-SQf`=L z5;=5w2;%82VMH6Xi&-K3W)o&K^+vJCepWZ-rW%+Dc6X3(){z$@4zjYxQ|}8UIojeC zYZpQ1dU{fy=oTr<4VX?$q)LP}IUmpiez^O&N3E_qPpchGTi5ZM6-2ScWlQq%V&R2Euz zO|Q0Hx>lY1Q1cW5xHv5!0OGU~PVEqSuy#fD72d#O`N!C;o=m+YioGu-wH2k6!t<~K zSr`E=W9)!g==~x9VV~-8{4ZN9{~-A9zJpRe%NGg$+MDuI-dH|b@BD)~>pPCGUNNzY zMDg||0@XGQgw`YCt5C&A{_+J}mvV9Wg{6V%2n#YSRN{AP#PY?1FF1#|vO_%e+#`|2*~wGAJaeRX6=IzFNeWhz6gJc8+(03Ph4y6ELAm=AkN7TOgMUEw*N{= z_)EIDQx5q22oUR+_b*tazu9+pX|n1c*IB-}{DqIj z-?E|ks{o3AGRNb;+iKcHkZvYJvFsW&83RAPs1Oh@IWy%l#5x2oUP6ZCtv+b|q>jsf zZ_9XO;V!>n`UxH1LvH8)L4?8raIvasEhkpQoJ`%!5rBs!0Tu(s_D{`4opB;57)pkX z4$A^8CsD3U5*!|bHIEqsn~{q+Ddj$ME@Gq4JXtgVz&7l{Ok!@?EA{B3P~NAqb9)4? zkQo30A^EbHfQ@87G5&EQTd`frrwL)&Yw?%-W@uy^Gn23%j?Y!Iea2xw<-f;esq zf%w5WN@E1}zyXtYv}}`U^B>W`>XPmdLj%4{P298|SisrE;7HvXX;A}Ffi8B#3Lr;1 zHt6zVb`8{#+e$*k?w8|O{Uh|&AG}|DG1PFo1i?Y*cQm$ZwtGcVgMwtBUDa{~L1KT-{jET4w60>{KZ27vXrHJ;fW{6| z=|Y4!&UX020wU1>1iRgB@Q#m~1^Z^9CG1LqDhYBrnx%IEdIty z!46iOoKlKs)c}newDG)rWUikD%j`)p z_w9Ph&e40=(2eBy;T!}*1p1f1SAUDP9iWy^u^Ubdj21Kn{46;GR+hwLO=4D11@c~V zI8x&(D({K~Df2E)Nx_yQvYfh4;MbMJ@Z}=Dt3_>iim~QZ*hZIlEs0mEb z_54+&*?wMD`2#vsQRN3KvoT>hWofI_Vf(^C1ff-Ike@h@saEf7g}<9T`W;HAne-Nd z>RR+&SP35w)xKn8^U$7))PsM!jKwYZ*RzEcG-OlTrX3}9a{q%#Un5E5W{{hp>w~;` zGky+3(vJvQyGwBo`tCpmo0mo((?nM8vf9aXrrY1Ve}~TuVkB(zeds^jEfI}xGBCM2 zL1|#tycSaWCurP+0MiActG3LCas@_@tao@(R1ANlwB$4K53egNE_;!&(%@Qo$>h`^1S_!hN6 z)vZtG$8fN!|BXBJ=SI>e(LAU(y(i*PHvgQ2llulxS8>qsimv7yL}0q_E5WiAz7)(f zC(ahFvG8&HN9+6^jGyLHM~$)7auppeWh_^zKk&C_MQ~8;N??OlyH~azgz5fe^>~7F zl3HnPN3z-kN)I$4@`CLCMQx3sG~V8hPS^}XDXZrQA>}mQPw%7&!sd(Pp^P=tgp-s^ zjl}1-KRPNWXgV_K^HkP__SR`S-|OF0bR-N5>I%ODj&1JUeAQ3$9i;B~$S6}*^tK?= z**%aCiH7y?xdY?{LgVP}S0HOh%0%LI$wRx;$T|~Y8R)Vdwa}kGWv8?SJVm^>r6+%I z#lj1aR94{@MP;t-scEYQWc#xFA30^}?|BeX*W#9OL;Q9#WqaaM546j5j29((^_8Nu z4uq}ESLr~r*O7E7$D{!k9W>`!SLoyA53i9QwRB{!pHe8um|aDE`Cg0O*{jmor)^t)3`>V>SWN-2VJcFmj^1?~tT=JrP`fVh*t zXHarp=8HEcR#vFe+1a%XXuK+)oFs`GDD}#Z+TJ}Ri`FvKO@ek2ayn}yaOi%(8p%2$ zpEu)v0Jym@f}U|-;}CbR=9{#<^z28PzkkTNvyKvJDZe+^VS2bES3N@Jq!-*}{oQlz z@8bgC_KnDnT4}d#&Cpr!%Yb?E!brx0!eVOw~;lLwUoz#Np%d$o%9scc3&zPm`%G((Le|6o1 zM(VhOw)!f84zG^)tZ1?Egv)d8cdNi+T${=5kV+j;Wf%2{3g@FHp^Gf*qO0q!u$=m9 zCaY`4mRqJ;FTH5`a$affE5dJrk~k`HTP_7nGTY@B9o9vvnbytaID;^b=Tzp7Q#DmD zC(XEN)Ktn39z5|G!wsVNnHi) z%^q94!lL|hF`IijA^9NR0F$@h7k5R^ljOW(;Td9grRN0Mb)l_l7##{2nPQ@?;VjXv zaLZG}yuf$r$<79rVPpXg?6iiieX|r#&`p#Con2i%S8*8F}(E) zI5E6c3tG*<;m~6>!&H!GJ6zEuhH7mkAzovdhLy;)q z{H2*8I^Pb}xC4s^6Y}6bJvMu=8>g&I)7!N!5QG$xseeU#CC?ZM-TbjsHwHgDGrsD= z{%f;@Sod+Ch66Ko2WF~;Ty)v>&x^aovCbCbD7>qF*!?BXmOV3(s|nxsb*Lx_2lpB7 zokUnzrk;P=T-&kUHO}td+Zdj!3n&NR?K~cRU zAXU!DCp?51{J4w^`cV#ye}(`SQhGQkkMu}O3M*BWt4UsC^jCFUy;wTINYmhD$AT;4 z?Xd{HaJjP`raZ39qAm;%beDbrLpbRf(mkKbANan7XsL>_pE2oo^$TgdidjRP!5-`% zv0d!|iKN$c0(T|L0C~XD0aS8t{*&#LnhE;1Kb<9&=c2B+9JeLvJr*AyyRh%@jHej=AetOMSlz^=!kxX>>B{2B1uIrQyfd8KjJ+DBy!h)~*(!|&L4^Q_07SQ~E zcemVP`{9CwFvPFu7pyVGCLhH?LhEVb2{7U+Z_>o25#+3<|8%1T^5dh}*4(kfJGry} zm%r#hU+__Z;;*4fMrX=Bkc@7|v^*B;HAl0((IBPPii%X9+u3DDF6%bI&6?Eu$8&aWVqHIM7mK6?Uvq$1|(-T|)IV<>e?!(rY zqkmO1MRaLeTR=)io(0GVtQT@s6rN%C6;nS3@eu;P#ry4q;^O@1ZKCJyp_Jo)Ty^QW z+vweTx_DLm{P-XSBj~Sl<%_b^$=}odJ!S2wAcxenmzFGX1t&Qp8Vxz2VT`uQsQYtdn&_0xVivIcxZ_hnrRtwq4cZSj1c-SG9 z7vHBCA=fd0O1<4*=lu$6pn~_pVKyL@ztw1swbZi0B?spLo56ZKu5;7ZeUml1Ws1?u zqMf1p{5myAzeX$lAi{jIUqo1g4!zWLMm9cfWcnw`k6*BR^?$2(&yW?>w;G$EmTA@a z6?y#K$C~ZT8+v{87n5Dm&H6Pb_EQ@V0IWmG9cG=O;(;5aMWWrIPzz4Q`mhK;qQp~a z+BbQrEQ+w{SeiuG-~Po5f=^EvlouB@_|4xQXH@A~KgpFHrwu%dwuCR)=B&C(y6J4J zvoGk9;lLs9%iA-IJGU#RgnZZR+@{5lYl8(e1h6&>Vc_mvg0d@);X zji4T|n#lB!>pfL|8tQYkw?U2bD`W{na&;*|znjmalA&f;*U++_aBYerq;&C8Kw7mI z7tsG*?7*5j&dU)Lje;^{D_h`%(dK|pB*A*1(Jj)w^mZ9HB|vGLkF1GEFhu&rH=r=8 zMxO42e{Si6$m+Zj`_mXb&w5Q(i|Yxyg?juUrY}78uo@~3v84|8dfgbPd0iQJRdMj< zncCNGdMEcsxu#o#B5+XD{tsg*;j-eF8`mp~K8O1J!Z0+>0=7O=4M}E?)H)ENE;P*F z$Ox?ril_^p0g7xhDUf(q652l|562VFlC8^r8?lQv;TMvn+*8I}&+hIQYh2 z1}uQQaag&!-+DZ@|C+C$bN6W;S-Z@)d1|en+XGvjbOxCa-qAF*LA=6s(Jg+g;82f$ z(Vb)8I)AH@cdjGFAR5Rqd0wiNCu!xtqWbcTx&5kslzTb^7A78~Xzw1($UV6S^VWiP zFd{Rimd-0CZC_Bu(WxBFW7+k{cOW7DxBBkJdJ;VsJ4Z@lERQr%3eVv&$%)b%<~ zCl^Y4NgO}js@u{|o~KTgH}>!* z_iDNqX2(As7T0xivMH|3SC1ivm8Q}6Ffcd7owUKN5lHAtzMM4<0v+ykUT!QiowO;`@%JGv+K$bBx@*S7C8GJVqQ_K>12}M`f_Ys=S zKFh}HM9#6Izb$Y{wYzItTy+l5U2oL%boCJn?R3?jP@n$zSIwlmyGq30Cw4QBO|14` zW5c);AN*J3&eMFAk$SR~2k|&+&Bc$e>s%c{`?d~85S-UWjA>DS5+;UKZ}5oVa5O(N zqqc@>)nee)+4MUjH?FGv%hm2{IlIF-QX}ym-7ok4Z9{V+ZHVZQl$A*x!(q%<2~iVv znUa+BX35&lCb#9VE-~Y^W_f;Xhl%vgjwdjzMy$FsSIj&ok}L+X`4>J=9BkN&nu^E*gbhj3(+D>C4E z@Fwq_=N)^bKFSHTzZk?-gNU$@l}r}dwGyh_fNi=9b|n}J>&;G!lzilbWF4B}BBq4f zYIOl?b)PSh#XTPp4IS5ZR_2C!E)Z`zH0OW%4;&~z7UAyA-X|sh9@~>cQW^COA9hV4 zXcA6qUo9P{bW1_2`eo6%hgbN%(G-F1xTvq!sc?4wN6Q4`e9Hku zFwvlAcRY?6h^Fj$R8zCNEDq8`=uZB8D-xn)tA<^bFFy}4$vA}Xq0jAsv1&5!h!yRA zU()KLJya5MQ`q&LKdH#fwq&(bNFS{sKlEh_{N%{XCGO+po#(+WCLmKW6&5iOHny>g z3*VFN?mx!16V5{zyuMWDVP8U*|BGT$(%IO|)?EF|OI*sq&RovH!N%=>i_c?K*A>>k zyg1+~++zY4Q)J;VWN0axhoIKx;l&G$gvj(#go^pZskEVj8^}is3Jw26LzYYVos0HX zRPvmK$dVxM8(Tc?pHFe0Z3uq){{#OK3i-ra#@+;*=ui8)y6hsRv z4Fxx1c1+fr!VI{L3DFMwXKrfl#Q8hfP@ajgEau&QMCxd{g#!T^;ATXW)nUg&$-n25 zruy3V!!;{?OTobo|0GAxe`Acn3GV@W=&n;~&9 zQM>NWW~R@OYORkJAo+eq1!4vzmf9K%plR4(tB@TR&FSbDoRgJ8qVcH#;7lQub*nq&?Z>7WM=oeEVjkaG zT#f)=o!M2DO5hLR+op>t0CixJCIeXH*+z{-XS|%jx)y(j&}Wo|3!l7{o)HU3m7LYyhv*xF&tq z%IN7N;D4raue&&hm0xM=`qv`+TK@;_xAcGKuK(2|75~ar2Yw)geNLSmVxV@x89bQu zpViVKKnlkwjS&&c|-X6`~xdnh}Ps)Hs z4VbUL^{XNLf7_|Oi>tA%?SG5zax}esF*FH3d(JH^Gvr7Rp*n=t7frH!U;!y1gJB^i zY_M$KL_}mW&XKaDEi9K-wZR|q*L32&m+2n_8lq$xRznJ7p8}V>w+d@?uB!eS3#u<} zIaqi!b!w}a2;_BfUUhGMy#4dPx>)_>yZ`ai?Rk`}d0>~ce-PfY-b?Csd(28yX22L% zI7XI>OjIHYTk_@Xk;Gu^F52^Gn6E1&+?4MxDS2G_#PQ&yXPXP^<-p|2nLTb@AAQEY zI*UQ9Pmm{Kat}wuazpjSyXCdnrD&|C1c5DIb1TnzF}f4KIV6D)CJ!?&l&{T)e4U%3HTSYqsQ zo@zWB1o}ceQSV)<4G<)jM|@@YpL+XHuWsr5AYh^Q{K=wSV99D~4RRU52FufmMBMmd z_H}L#qe(}|I9ZyPRD6kT>Ivj&2Y?qVZq<4bG_co_DP`sE*_Xw8D;+7QR$Uq(rr+u> z8bHUWbV19i#)@@G4bCco@Xb<8u~wVDz9S`#k@ciJtlu@uP1U0X?yov8v9U3VOig2t zL9?n$P3=1U_Emi$#slR>N5wH-=J&T=EdUHA}_Z zZIl3nvMP*AZS9{cDqFanrA~S5BqxtNm9tlu;^`)3X&V4tMAkJ4gEIPl= zoV!Gyx0N{3DpD@)pv^iS*dl2FwANu;1;%EDl}JQ7MbxLMAp>)UwNwe{=V}O-5C*>F zu?Ny+F64jZn<+fKjF01}8h5H_3pey|;%bI;SFg$w8;IC<8l|3#Lz2;mNNik6sVTG3 z+Su^rIE#40C4a-587$U~%KedEEw1%r6wdvoMwpmlXH$xPnNQN#f%Z7|p)nC>WsuO= z4zyqapLS<8(UJ~Qi9d|dQijb_xhA2)v>la)<1md5s^R1N&PiuA$^k|A<+2C?OiHbj z>Bn$~t)>Y(Zb`8hW7q9xQ=s>Rv81V+UiuZJc<23HplI88isqRCId89fb`Kt|CxVIg znWcwprwXnotO>3s&Oypkte^9yJjlUVVxSe%_xlzmje|mYOVPH^vjA=?6xd0vaj0Oz zwJ4OJNiFdnHJX3rw&inskjryukl`*fRQ#SMod5J|KroJRsVXa5_$q7whSQ{gOi*s0 z1LeCy|JBWRsDPn7jCb4s(p|JZiZ8+*ExC@Vj)MF|*Vp{B(ziccSn`G1Br9bV(v!C2 z6#?eqpJBc9o@lJ#^p-`-=`4i&wFe>2)nlPK1p9yPFzJCzBQbpkcR>={YtamIw)3nt z(QEF;+)4`>8^_LU)_Q3 zC5_7lgi_6y>U%m)m@}Ku4C}=l^J=<<7c;99ec3p{aR+v=diuJR7uZi%aQv$oP?dn?@6Yu_+*^>T0ptf(oobdL;6)N-I!TO`zg^Xbv3#L0I~sn@WGk-^SmPh5>W+LB<+1PU}AKa?FCWF|qMNELOgdxR{ zbqE7@jVe+FklzdcD$!(A$&}}H*HQFTJ+AOrJYnhh}Yvta(B zQ_bW4Rr;R~&6PAKwgLWXS{Bnln(vUI+~g#kl{r+_zbngT`Y3`^Qf=!PxN4IYX#iW4 zucW7@LLJA9Zh3(rj~&SyN_pjO8H&)|(v%!BnMWySBJV=eSkB3YSTCyIeJ{i;(oc%_hk{$_l;v>nWSB)oVeg+blh=HB5JSlG_r7@P z3q;aFoZjD_qS@zygYqCn=;Zxjo!?NK!%J$ z52lOP`8G3feEj+HTp@Tnn9X~nG=;tS+z}u{mQX_J0kxtr)O30YD%oo)L@wy`jpQYM z@M>Me=95k1p*FW~rHiV1CIfVc{K8r|#Kt(ApkXKsDG$_>76UGNhHExFCw#Ky9*B-z zNq2ga*xax!HMf_|Vp-86r{;~YgQKqu7%szk8$hpvi_2I`OVbG1doP(`gn}=W<8%Gn z%81#&WjkH4GV;4u43EtSW>K_Ta3Zj!XF?;SO3V#q=<=>Tc^@?A`i;&`-cYj|;^ zEo#Jl5zSr~_V-4}y8pnufXLa80vZY4z2ko7fj>DR)#z=wWuS1$$W!L?(y}YC+yQ|G z@L&`2upy3f>~*IquAjkVNU>}c10(fq#HdbK$~Q3l6|=@-eBbo>B9(6xV`*)sae58*f zym~RRVx;xoCG3`JV`xo z!lFw)=t2Hy)e!IFs?0~7osWk(d%^wxq&>_XD4+U#y&-VF%4z?XH^i4w`TxpF{`XhZ z%G}iEzf!T(l>g;W9<~K+)$g!{UvhW{E0Lis(S^%I8OF&%kr!gJ&fMOpM=&=Aj@wuL zBX?*6i51Qb$uhkwkFYkaD_UDE+)rh1c;(&Y=B$3)J&iJfQSx!1NGgPtK!$c9OtJuu zX(pV$bfuJpRR|K(dp@^j}i&HeJOh@|7lWo8^$*o~Xqo z5Sb+!EtJ&e@6F+h&+_1ETbg7LfP5GZjvIUIN3ibCOldAv z)>YdO|NH$x7AC8dr=<2ekiY1%fN*r~e5h6Yaw<{XIErujKV~tiyrvV_DV0AzEknC- zR^xKM3i<1UkvqBj3C{wDvytOd+YtDSGu!gEMg+!&|8BQrT*|p)(dwQLEy+ zMtMzij3zo40)CA!BKZF~yWg?#lWhqD3@qR)gh~D{uZaJO;{OWV8XZ_)J@r3=)T|kt zUS1pXr6-`!Z}w2QR7nP%d?ecf90;K_7C3d!UZ`N(TZoWNN^Q~RjVhQG{Y<%E1PpV^4 z-m-K+$A~-+VDABs^Q@U*)YvhY4Znn2^w>732H?NRK(5QSS$V@D7yz2BVX4)f5A04~$WbxGOam22>t&uD)JB8-~yiQW6ik;FGblY_I>SvB_z2?PS z*Qm&qbKI{H1V@YGWzpx`!v)WeLT02};JJo*#f$a*FH?IIad-^(;9XC#YTWN6;Z6+S zm4O1KH=#V@FJw7Pha0!9Vb%ZIM$)a`VRMoiN&C|$YA3~ZC*8ayZRY^fyuP6$n%2IU z$#XceYZeqLTXw(m$_z|33I$B4k~NZO>pP6)H_}R{E$i%USGy{l{-jOE;%CloYPEU+ zRFxOn4;7lIOh!7abb23YKD+_-?O z0FP9otcAh+oSj;=f#$&*ExUHpd&e#bSF%#8*&ItcL2H$Sa)?pt0Xtf+t)z$_u^wZi z44oE}r4kIZGy3!Mc8q$B&6JqtnHZ>Znn!Zh@6rgIu|yU+zG8q`q9%B18|T|oN3zMq z`l&D;U!OL~%>vo&q0>Y==~zLiCZk4v%s_7!9DxQ~id1LLE93gf*gg&2$|hB#j8;?3 z5v4S;oM6rT{Y;I+#FdmNw z){d%tNM<<#GN%n9ox7B=3#;u7unZ~tLB_vRZ52a&2=IM)2VkXm=L+Iqq~uk#Dug|x z>S84e+A7EiOY5lj*!q?6HDkNh~0g;0Jy(al!ZHHDtur9T$y-~)94HelX1NHjXWIM7UAe}$?jiz z9?P4`I0JM=G5K{3_%2jPLC^_Mlw?-kYYgb7`qGa3@dn|^1fRMwiyM@Ch z;CB&o7&&?c5e>h`IM;Wnha0QKnEp=$hA8TJgR-07N~U5(>9vJzeoFsSRBkDq=x(YgEMpb=l4TDD`2 zwVJpWGTA_u7}?ecW7s6%rUs&NXD3+n;jB86`X?8(l3MBo6)PdakI6V6a}22{)8ilT zM~T*mU}__xSy|6XSrJ^%lDAR3Lft%+yxC|ZUvSO_nqMX!_ul3;R#*{~4DA=h$bP)%8Yv9X zyp><|e8=_ttI}ZAwOd#dlnSjck#6%273{E$kJuCGu=I@O)&6ID{nWF5@gLb16sj|&Sb~+du4e4O_%_o`Ix4NRrAsyr1_}MuP94s>de8cH-OUkVPk3+K z&jW)It9QiU-ti~AuJkL`XMca8Oh4$SyJ=`-5WU<{cIh+XVH#e4d&zive_UHC!pN>W z3TB;Mn5i)9Qn)#6@lo4QpI3jFYc0~+jS)4AFz8fVC;lD^+idw^S~Qhq>Tg(!3$yLD zzktzoFrU@6s4wwCMz}edpF5i5Q1IMmEJQHzp(LAt)pgN3&O!&d?3W@6U4)I^2V{;- z6A(?zd93hS*uQmnh4T)nHnE{wVhh(=MMD(h(P4+^p83Om6t<*cUW>l(qJzr%5vp@K zN27ka(L{JX=1~e2^)F^i=TYj&;<7jyUUR2Bek^A8+3Up*&Xwc{)1nRR5CT8vG>ExV zHnF3UqXJOAno_?bnhCX-&kwI~Ti8t4`n0%Up>!U`ZvK^w2+0Cs-b9%w%4`$+To|k= zKtgc&l}P`*8IS>8DOe?EB84^kx4BQp3<7P{Pq}&p%xF_81pg!l2|u=&I{AuUgmF5n zJQCTLv}%}xbFGYtKfbba{CBo)lWW%Z>i(_NvLhoQZ*5-@2l&x>e+I~0Nld3UI9tdL zRzu8}i;X!h8LHVvN?C+|M81e>Jr38%&*9LYQec9Ax>?NN+9(_>XSRv&6hlCYB`>Qm z1&ygi{Y()OU4@D_jd_-7vDILR{>o|7-k)Sjdxkjgvi{@S>6GqiF|o`*Otr;P)kLHN zZkpts;0zw_6;?f(@4S1FN=m!4^mv~W+lJA`&7RH%2$)49z0A+8@0BCHtj|yH--AEL z0tW6G%X-+J+5a{5*WKaM0QDznf;V?L5&uQw+yegDNDP`hA;0XPYc6e0;Xv6|i|^F2WB)Z$LR|HR4 zTQsRAby9(^Z@yATyOgcfQw7cKyr^3Tz7lc7+JEwwzA7)|2x+PtEb>nD(tpxJQm)Kn zW9K_*r!L%~N*vS8<5T=iv|o!zTe9k_2jC_j*7ik^M_ zaf%k{WX{-;0*`t`G!&`eW;gChVXnJ-Rn)To8vW-?>>a%QU1v`ZC=U)f8iA@%JG0mZ zDqH;~mgBnrCP~1II<=V9;EBL)J+xzCoiRBaeH&J6rL!{4zIY8tZka?_FBeQeNO3q6 zyG_alW54Ba&wQf{&F1v-r1R6ID)PTsqjIBc+5MHkcW5Fnvi~{-FjKe)t1bl}Y;z@< z=!%zvpRua>>t_x}^}z0<7MI!H2v6|XAyR9!t50q-A)xk0nflgF4*OQlCGK==4S|wc zRMsSscNhRzHMBU8TdcHN!q^I}x0iXJ%uehac|Zs_B$p@CnF)HeXPpB_Za}F{<@6-4 zl%kml@}kHQ(ypD8FsPJ2=14xXJE|b20RUIgs!2|R3>LUMGF6X*B_I|$`Qg=;zm7C z{mEDy9dTmPbued7mlO@phdmAmJ7p@GR1bjCkMw6*G7#4+`k>fk1czdJUB!e@Q(~6# zwo%@p@V5RL0ABU2LH7Asq^quDUho@H>eTZH9f*no9fY0T zD_-9px3e}A!>>kv5wk91%C9R1J_Nh!*&Kk$J3KNxC}c_@zlgpJZ+5L)Nw|^p=2ue}CJtm;uj*Iqr)K})kA$xtNUEvX;4!Px*^&9T_`IN{D z{6~QY=Nau6EzpvufB^hflc#XIsSq0Y9(nf$d~6ZwK}fal92)fr%T3=q{0mP-EyP_G z)UR5h@IX}3Qll2b0oCAcBF>b*@Etu*aTLPU<%C>KoOrk=x?pN!#f_Og-w+;xbFgjQ zXp`et%lDBBh~OcFnMKMUoox0YwBNy`N0q~bSPh@+enQ=4RUw1) zpovN`QoV>vZ#5LvC;cl|6jPr}O5tu!Ipoyib8iXqy}TeJ;4+_7r<1kV0v5?Kv>fYp zg>9L`;XwXa&W7-jf|9~uP2iyF5`5AJ`Q~p4eBU$MCC00`rcSF>`&0fbd^_eqR+}mK z4n*PMMa&FOcc)vTUR zlDUAn-mh`ahi_`f`=39JYTNVjsTa_Y3b1GOIi)6dY)D}xeshB0T8Eov5%UhWd1)u}kjEQ|LDo{tqKKrYIfVz~@dp!! zMOnah@vp)%_-jDTUG09l+;{CkDCH|Q{NqX*uHa1YxFShy*1+;J`gywKaz|2Q{lG8x zP?KBur`}r`!WLKXY_K;C8$EWG>jY3UIh{+BLv0=2)KH%P}6xE2kg)%(-uA6lC?u8}{K(#P*c zE9C8t*u%j2r_{;Rpe1A{9nNXU;b_N0vNgyK!EZVut~}+R2rcbsHilqsOviYh-pYX= zHw@53nlmwYI5W5KP>&`dBZe0Jn?nAdC^HY1wlR6$u^PbpB#AS&5L6zqrXN&7*N2Q` z+Rae1EwS)H=aVSIkr8Ek^1jy2iS2o7mqm~Mr&g5=jjt7VxwglQ^`h#Mx+x2v|9ZAwE$i_9918MjJxTMr?n!bZ6n$}y11u8I9COTU`Z$Fi z!AeAQLMw^gp_{+0QTEJrhL424pVDp%wpku~XRlD3iv{vQ!lAf!_jyqd_h}+Tr1XG| z`*FT*NbPqvHCUsYAkFnM`@l4u_QH&bszpUK#M~XLJt{%?00GXY?u_{gj3Hvs!=N(I z(=AuWPijyoU!r?aFTsa8pLB&cx}$*%;K$e*XqF{~*rA-qn)h^!(-;e}O#B$|S~c+U zN4vyOK0vmtx$5K!?g*+J@G1NmlEI=pyZXZ69tAv=@`t%ag_Hk{LP~OH9iE)I= zaJ69b4kuCkV0V zo(M0#>phpQ_)@j;h%m{-a*LGi(72TP)ws2w*@4|C-3+;=5DmC4s7Lp95%n%@Ko zfdr3-a7m*dys9iIci$A=4NPJ`HfJ;hujLgU)ZRuJI`n;Pw|yksu!#LQnJ#dJysgNb z@@qwR^wrk(jbq4H?d!lNyy72~Dnn87KxsgQ!)|*m(DRM+eC$wh7KnS-mho3|KE)7h zK3k;qZ;K1Lj6uEXLYUYi)1FN}F@-xJ z@@3Hb84sl|j{4$3J}aTY@cbX@pzB_qM~APljrjju6P0tY{C@ zpUCOz_NFmALMv1*blCcwUD3?U6tYs+N%cmJ98D%3)%)Xu^uvzF zS5O!sc#X6?EwsYkvPo6A%O8&y8sCCQH<%f2togVwW&{M;PR!a(ZT_A+jVAbf{@5kL zB@Z(hb$3U{T_}SKA_CoQVU-;j>2J=L#lZ~aQCFg-d<9rzs$_gO&d5N6eFSc z1ml8)P*FSi+k@!^M9nDWR5e@ATD8oxtDu=36Iv2!;dZzidIS(PCtEuXAtlBb1;H%Z zwnC^Ek*D)EX4#Q>R$$WA2sxC_t(!!6Tr?C#@{3}n{<^o;9id1RA&-Pig1e-2B1XpG zliNjgmd3c&%A}s>qf{_j#!Z`fu0xIwm4L0)OF=u(OEmp;bLCIaZX$&J_^Z%4Sq4GZ zPn6sV_#+6pJmDN_lx@1;Zw6Md_p0w9h6mHtzpuIEwNn>OnuRSC2=>fP^Hqgc)xu^4 z<3!s`cORHJh#?!nKI`Et7{3C27+EuH)Gw1f)aoP|B3y?fuVfvpYYmmukx0ya-)TQX zR{ggy5cNf4X|g)nl#jC9p>7|09_S7>1D2GTRBUTW zAkQ=JMRogZqG#v;^=11O6@rPPwvJkr{bW-Qg8`q8GoD#K`&Y+S#%&B>SGRL>;ZunM@49!}Uy zN|bBCJ%sO;@3wl0>0gbl3L@1^O60ONObz8ZI7nder>(udj-jt`;yj^nTQ$L9`OU9W zX4alF#$|GiR47%x@s&LV>2Sz2R6?;2R~5k6V>)nz!o_*1Y!$p>BC5&?hJg_MiE6UBy>RkVZj`9UWbRkN-Hk!S`=BS3t3uyX6)7SF#)71*}`~Ogz z1rap5H6~dhBJ83;q-Y<5V35C2&F^JI-it(=5D#v!fAi9p#UwV~2tZQI+W(Dv?1t9? zfh*xpxxO{-(VGB>!Q&0%^YW_F!@aZS#ucP|YaD#>wd1Fv&Z*SR&mc;asi}1G) z_H>`!akh-Zxq9#io(7%;a$)w+{QH)Y$?UK1Dt^4)up!Szcxnu}kn$0afcfJL#IL+S z5gF_Y30j;{lNrG6m~$Ay?)*V9fZuU@3=kd40=LhazjFrau>(Y>SJNtOz>8x_X-BlA zIpl{i>OarVGj1v(4?^1`R}aQB&WCRQzS~;7R{tDZG=HhgrW@B`W|#cdyj%YBky)P= zpxuOZkW>S6%q7U{VsB#G(^FMsH5QuGXhb(sY+!-R8Bmv6Sx3WzSW<1MPPN1!&PurYky(@`bP9tz z52}LH9Q?+FF5jR6-;|+GVdRA!qtd;}*-h&iIw3Tq3qF9sDIb1FFxGbo&fbG5n8$3F zyY&PWL{ys^dTO}oZ#@sIX^BKW*bon=;te9j5k+T%wJ zNJtoN1~YVj4~YRrlZl)b&kJqp+Z`DqT!la$x&&IxgOQw#yZd-nBP3!7FijBXD|IsU8Zl^ zc6?MKpJQ+7ka|tZQLfchD$PD|;K(9FiLE|eUZX#EZxhG!S-63C$jWX1Yd!6-Yxi-u zjULIr|0-Q%D9jz}IF~S%>0(jOqZ(Ln<$9PxiySr&2Oic7vb<8q=46)Ln%Z|<*z5&> z3f~Zw@m;vR(bESB<=Jqkxn(=#hQw42l(7)h`vMQQTttz9XW6^|^8EK7qhju4r_c*b zJIi`)MB$w@9epwdIfnEBR+?~);yd6C(LeMC& zn&&N*?-g&BBJcV;8&UoZi4Lmxcj16ojlxR~zMrf=O_^i1wGb9X-0@6_rpjPYemIin zmJb+;lHe;Yp=8G)Q(L1bzH*}I>}uAqhj4;g)PlvD9_e_ScR{Ipq|$8NvAvLD8MYr}xl=bU~)f%B3E>r3Bu9_t|ThF3C5~BdOve zEbk^r&r#PT&?^V1cb{72yEWH}TXEE}w>t!cY~rA+hNOTK8FAtIEoszp!qqptS&;r$ zaYV-NX96-h$6aR@1xz6_E0^N49mU)-v#bwtGJm)ibygzJ8!7|WIrcb`$XH~^!a#s& z{Db-0IOTFq#9!^j!n_F}#Z_nX{YzBK8XLPVmc&X`fT7!@$U-@2KM9soGbmOSAmqV z{nr$L^MBo_u^Joyf0E^=eo{Rt0{{e$IFA(#*kP@SQd6lWT2-#>` zP1)7_@IO!9lk>Zt?#CU?cuhiLF&)+XEM9B)cS(gvQT!X3`wL*{fArTS;Ak`J<84du zALKPz4}3nlG8Fo^MH0L|oK2-4xIY!~Oux~1sw!+It)&D3p;+N8AgqKI`ld6v71wy8I!eP0o~=RVcFQR2Gr(eP_JbSytoQ$Yt}l*4r@A8Me94y z8cTDWhqlq^qoAhbOzGBXv^Wa4vUz$(7B!mX`T=x_ueKRRDfg&Uc-e1+z4x$jyW_Pm zp?U;-R#xt^Z8Ev~`m`iL4*c#65Nn)q#=Y0l1AuD&+{|8-Gsij3LUZXpM0Bx0u7WWm zH|%yE@-#XEph2}-$-thl+S;__ciBxSSzHveP%~v}5I%u!z_l_KoW{KRx2=eB33umE zIYFtu^5=wGU`Jab8#}cnYry@9p5UE#U|VVvx_4l49JQ;jQdp(uw=$^A$EA$LM%vmE zvdEOaIcp5qX8wX{mYf0;#51~imYYPn4=k&#DsKTxo{_Mg*;S495?OBY?#gv=edYC* z^O@-sd-qa+U24xvcbL0@C7_6o!$`)sVr-jSJE4XQUQ$?L7}2(}Eixqv;L8AdJAVqc zq}RPgpnDb@E_;?6K58r3h4-!4rT4Ab#rLHLX?eMOfluJk=3i1@Gt1i#iA=O`M0@x! z(HtJP9BMHXEzuD93m|B&woj0g6T?f#^)>J>|I4C5?Gam>n9!8CT%~aT;=oco5d6U8 zMXl(=W;$ND_8+DD*?|5bJ!;8ebESXMUKBAf7YBwNVJibGaJ*(2G`F%wx)grqVPjudiaq^Kl&g$8A2 zWMxMr@_$c}d+;_B`#kUX-t|4VKH&_f^^EP0&=DPLW)H)UzBG%%Tra*5 z%$kyZe3I&S#gfie^z5)!twG={3Cuh)FdeA!Kj<-9** zvT*5%Tb`|QbE!iW-XcOuy39>D3oe6x{>&<#E$o8Ac|j)wq#kQzz|ATd=Z0K!p2$QE zPu?jL8Lb^y3_CQE{*}sTDe!2!dtlFjq&YLY@2#4>XS`}v#PLrpvc4*@q^O{mmnr5D zmyJq~t?8>FWU5vZdE(%4cuZuao0GNjp3~Dt*SLaxI#g_u>hu@k&9Ho*#CZP~lFJHj z(e!SYlLigyc?&5-YxlE{uuk$9b&l6d`uIlpg_z15dPo*iU&|Khx2*A5Fp;8iK_bdP z?T6|^7@lcx2j0T@x>X7|kuuBSB7<^zeY~R~4McconTxA2flHC0_jFxmSTv-~?zVT| zG_|yDqa9lkF*B6_{j=T>=M8r<0s;@z#h)3BQ4NLl@`Xr__o7;~M&dL3J8fP&zLfDfy z);ckcTev{@OUlZ`bCo(-3? z1u1xD`PKgSg?RqeVVsF<1SLF;XYA@Bsa&cY!I48ZJn1V<3d!?s=St?TLo zC0cNr`qD*M#s6f~X>SCNVkva^9A2ZP>CoJ9bvgXe_c}WdX-)pHM5m7O zrHt#g$F0AO+nGA;7dSJ?)|Mo~cf{z2L)Rz!`fpi73Zv)H=a5K)*$5sf_IZypi($P5 zsPwUc4~P-J1@^3C6-r9{V-u0Z&Sl7vNfmuMY4yy*cL>_)BmQF!8Om9Dej%cHxbIzA zhtV0d{=%cr?;bpBPjt@4w=#<>k5ee=TiWAXM2~tUGfm z$s&!Dm0R^V$}fOR*B^kGaipi~rx~A2cS0;t&khV1a4u38*XRUP~f za!rZMtay8bsLt6yFYl@>-y^31(*P!L^^s@mslZy(SMsv9bVoX`O#yBgEcjCmGpyc* zeH$Dw6vB5P*;jor+JOX@;6K#+xc)Z9B8M=x2a@Wx-{snPGpRmOC$zpsqW*JCh@M2Y z#K+M(>=#d^>Of9C`))h<=Bsy)6zaMJ&x-t%&+UcpLjV`jo4R2025 zXaG8EA!0lQa)|dx-@{O)qP6`$rhCkoQqZ`^SW8g-kOwrwsK8 z3ms*AIcyj}-1x&A&vSq{r=QMyp3CHdWH35!sad#!Sm>^|-|afB+Q;|Iq@LFgqIp#Z zD1%H+3I?6RGnk&IFo|u+E0dCxXz4yI^1i!QTu7uvIEH>i3rR{srcST`LIRwdV1P;W z+%AN1NIf@xxvVLiSX`8ILA8MzNqE&7>%jMzGt9wm78bo9<;h*W84i29^w!>V>{N+S zd`5Zmz^G;f=icvoOZfK5#1ctx*~UwD=ab4DGQXehQ!XYnak*dee%YN$_ZPL%KZuz$ zD;$PpT;HM^$KwtQm@7uvT`i6>Hae1CoRVM2)NL<2-k2PiX=eAx+-6j#JI?M}(tuBW zkF%jjLR)O`gI2fcPBxF^HeI|DWwQWHVR!;;{BXXHskxh8F@BMDn`oEi-NHt;CLymW z=KSv5)3dyzec0T5B*`g-MQ<;gz=nIWKUi9ko<|4I(-E0k$QncH>E4l z**1w&#={&zv4Tvhgz#c29`m|;lU-jmaXFMC11 z*dlXDMEOG>VoLMc>!rApwOu2prKSi*!w%`yzGmS+k(zm*CsLK*wv{S_0WX^8A-rKy zbk^Gf_92^7iB_uUF)EE+ET4d|X|>d&mdN?x@vxKAQk`O+r4Qdu>XGy(a(19g;=jU} zFX{O*_NG>!$@jh!U369Lnc+D~qch3uT+_Amyi}*k#LAAwh}k8IPK5a-WZ81ufD>l> z$4cF}GSz>ce`3FAic}6W4Z7m9KGO?(eWqi@L|5Hq0@L|&2flN1PVl}XgQ2q*_n2s3 zt5KtowNkTYB5b;SVuoXA@i5irXO)A&%7?V`1@HGCB&)Wgk+l|^XXChq;u(nyPB}b3 zY>m5jkxpZgi)zfbgv&ec4Zqdvm+D<?Im*mXweS9H+V>)zF#Zp3)bhl$PbISY{5=_z!8&*Jv~NYtI-g!>fDs zmvL5O^U%!^VaKA9gvKw|5?-jk>~%CVGvctKmP$kpnpfN{D8@X*Aazi$txfa%vd-|E z>kYmV66W!lNekJPom29LdZ%(I+ZLZYTXzTg*to~m?7vp%{V<~>H+2}PQ?PPAq`36R z<%wR8v6UkS>Wt#hzGk#44W<%9S=nBfB);6clKwnxY}T*w21Qc3_?IJ@4gYzC7s;WP zVQNI(M=S=JT#xsZy7G`cR(BP9*je0bfeN8JN5~zY(DDs0t{LpHOIbN);?T-69Pf3R zSNe*&p2%AwXHL>__g+xd4Hlc_vu<25H?(`nafS%)3UPP7_4;gk-9ckt8SJRTv5v0M z_Hww`qPudL?ajIR&X*;$y-`<)6dxx1U~5eGS13CB!lX;3w7n&lDDiArbAhSycd}+b zya_3p@A`$kQy;|NJZ~s44Hqo7Hwt}X86NK=(ey>lgWTtGL6k@Gy;PbO!M%1~Wcn2k zUFP|*5d>t-X*RU8g%>|(wwj*~#l4z^Aatf^DWd1Wj#Q*AY0D^V@sC`M zjJc6qXu0I7Y*2;;gGu!plAFzG=J;1%eIOdn zQA>J&e05UN*7I5@yRhK|lbBSfJ+5Uq;!&HV@xfPZrgD}kE*1DSq^=%{o%|LChhl#0 zlMb<^a6ixzpd{kNZr|3jTGeEzuo}-eLT-)Q$#b{!vKx8Tg}swCni>{#%vDY$Ww$84 zew3c9BBovqb}_&BRo#^!G(1Eg((BScRZ}C)Oz?y`T5wOrv);)b^4XR8 zhJo7+<^7)qB>I;46!GySzdneZ>n_E1oWZY;kf94#)s)kWjuJN1c+wbVoNQcmnv}{> zN0pF+Sl3E}UQ$}slSZeLJrwT>Sr}#V(dVaezCQl2|4LN`7L7v&siYR|r7M(*JYfR$ zst3=YaDw$FSc{g}KHO&QiKxuhEzF{f%RJLKe3p*7=oo`WNP)M(9X1zIQPP0XHhY3c znrP{$4#Ol$A0s|4S7Gx2L23dv*Gv2o;h((XVn+9+$qvm}s%zi6nI-_s6?mG! zj{DV;qesJb&owKeEK?=J>UcAlYckA7Sl+I&IN=yasrZOkejir*kE@SN`fk<8Fgx*$ zy&fE6?}G)d_N`){P~U@1jRVA|2*69)KSe_}!~?+`Yb{Y=O~_+@!j<&oVQQMnhoIRU zA0CyF1OFfkK44n*JD~!2!SCPM;PRSk%1XL=0&rz00wxPs&-_eapJy#$h!eqY%nS0{ z!aGg58JIJPF3_ci%n)QSVpa2H`vIe$RD43;#IRfDV&Ibit z+?>HW4{2wOfC6Fw)}4x}i1maDxcE1qi@BS*qcxD2gE@h3#4cgU*D-&3z7D|tVZWt= z-Cy2+*Cm@P4GN_TPUtaVyVesbVDazF@)j8VJ4>XZv!f%}&eO1SvIgr}4`A*3#vat< z_MoByL(qW6L7SFZ#|Gc1fFN)L2PxY+{B8tJp+pxRyz*87)vXR}*=&ahXjBlQKguuf zX6x<<6fQulE^C*KH8~W%ptpaC0l?b=_{~*U4?5Vt;dgM4t_{&UZ1C2j?b>b+5}{IF_CUyvz-@QZPMlJ)r_tS$9kH%RPv#2_nMb zRLj5;chJ72*U`Z@Dqt4$@_+k$%|8m(HqLG!qT4P^DdfvGf&){gKnGCX#H0!;W=AGP zbA&Z`-__a)VTS}kKFjWGk z%|>yE?t*EJ!qeQ%dPk$;xIQ+P0;()PCBDgjJm6Buj{f^awNoVx+9<|lg3%-$G(*f) zll6oOkN|yamn1uyl2*N-lnqRI1cvs_JxLTeahEK=THV$Sz*gQhKNb*p0fNoda#-&F zB-qJgW^g}!TtM|0bS2QZekW7_tKu%GcJ!4?lObt0z_$mZ4rbQ0o=^curCs3bJK6sq z9fu-aW-l#>z~ca(B;4yv;2RZ?tGYAU)^)Kz{L|4oPj zdOf_?de|#yS)p2v8-N||+XL=O*%3+y)oI(HbM)Ds?q8~HPzIP(vs*G`iddbWq}! z(2!VjP&{Z1w+%eUq^ '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/wicket/gradlew.bat b/wicket/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/wicket/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/wicket/settings.gradle b/wicket/settings.gradle new file mode 100644 index 0000000..f66ecb4 --- /dev/null +++ b/wicket/settings.gradle @@ -0,0 +1 @@ +rootProject.name = "kontor-wicket" diff --git a/wicket/src/docs/asciidoc/kontor-wicket.adoc b/wicket/src/docs/asciidoc/kontor-wicket.adoc new file mode 100644 index 0000000..e310b6f --- /dev/null +++ b/wicket/src/docs/asciidoc/kontor-wicket.adoc @@ -0,0 +1,179 @@ += Projektbeschreibung kontor-wicket: Entwicklungs- und Projekthandbuch +:author: Thomas Peetz +:email: +:doctype: book +:sectnums: +:sectnumlevels: 4 +:toc: +:toclevels: 4 +:table-caption!: +:counter: table-number: 0 + +[title="Dokumenthistorie", id="Table-{counter:table-number}", options="header"] +|=== +| Version | Datum | Autor | Änderungsgrund / Bemerkungen +| 1.0.0 | 16.05.2022 | Thomas Peetz | Ersterstellung +|=== + +== Allgemeines + +=== Zweck des Dokumentes + +Das Entwicklungshandbuch beschreibt die Werkzeuge und die Vorgehensweise bei der Entwicklung +im Projekt kontor-wicket und der Erstellung der Dokumentation. + +=== Verwendete Tools + +==== Gitlab + +Für die Verwaltung des Sourcecode kommt ((Gitlab))<> zum Einsatz. +Mit Gitlab werden auch die Projektaufgaben verwaltet. + +Das Projekt und das dazugehörige Git Repository sind unter der Adresse + +https://gitlab.ingenieurbuero-peetz.de/kontor/kontor-wicket + +zu finden. + +== Erstellung der Dokumentation + +Die Dokumentation des Projektes wird mit ((Asciidoctor))<> geschrieben. +Die Dokumente erhalten ihre Namen nach dem jeweiligen Hauptdokument. + +=== Quellcode Verwaltung + +Die Asciidoctor-Dateien haben die Endung `.adoc`. + +=== Buildsystem + +Zur Erstellung der PDF-Dateien aus den Asciidoctor-Dateien wird das Buildsystem ((Gradle))<<3>> verwendet. +Die Dateien für die Dokumente liegen im Verzeichnis `src/docs/asciidoc`. + +Der Gradle Build wird über die Datei `build.gradle` definiert. + + +== Einführung + +=== Zweck + +=== Stakeholder des Systems + +=== Systemumfang + +==== Zielsetzung des Systems + +=== Systemübersicht + +==== Systemkontext + +==== Systemarchitektur + +==== Systemschnittstellen + +===== Realisierte Schnittstellen + +===== Verwendete Schnittstellen + +==== Logisches Datenmodell + +==== Einschränkungen + +== Anforderungen der Domäne + +=== Systemfunktionen + +==== Anwendungsfälle + +==== Akteure + +==== Zielgruppen + +=== Anforderungen + +==== Anforderungen an externe Schnittstellen + +==== Funktionale Anforderungen + +==== Qualitätsanforderungen + +==== Randbedingungen + +==== Weitere Anforderungen + +==== Wartungs- und Supportinformationen + +=== Verifikation + +== Projektbeschreibung + +=== Ausgangslage + +//==== Rechtliche Vorgaben und Rahmenbedingungen +//=== Rahmenbedingungen + +//==== Vorhandene Regelungen + +=== Projektziele + +=== Projektabgrenzung + +//=== Voraussichtliche Kosten + +//=== Projektrisiken + +//==== Produktivität + +//==== Finanzielle Risiken + +//==== Akzeptanz + +== Projektorganisation + +=== Projekt-Aufbauorganisation + +=== Rollendefinition + +//==== Projektauftraggeber + +//==== Projektausschuss + +//==== Beratung / Qualitätssicherung + +==== Projekteiter + +==== Projektteam + +==== Liste der Stakeholder + +=== Projektablauforganisation + +==== Projekt-Phasen + +===== Erstellung der Projektdokumentation + + +== Verschiedenes + +=== Erreichbarkeiten + +[bibliography] +== Referenzen + +- [[[asciidoctor]]] http://asciidoctor.org +- [[[gitlab]]] http://www.gitlab.org +- [[[gradle]]] http://www.gradle.org +- [[[jenkins]]] http://jenkins-ci.org + +[glossary] +== Glossar + +[index] +== Index + +== Verzeichnisse + +=== Abbildungsverzeichnis + +=== Tabellenverzeichnis + +<> <> diff --git a/wicket/src/main/java/de/thpeetz/kontor/HomePage.java b/wicket/src/main/java/de/thpeetz/kontor/HomePage.java new file mode 100644 index 0000000..e5a3c82 --- /dev/null +++ b/wicket/src/main/java/de/thpeetz/kontor/HomePage.java @@ -0,0 +1,18 @@ +package de.thpeetz.kontor; + +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.WebPage; + +public class HomePage extends WebPage { + private static final long serialVersionUID = 1L; + + public HomePage(final PageParameters parameters) { + super(parameters); + + add(new Label("version", getApplication().getFrameworkSettings().getVersion())); + + // TODO Add your page's components here + + } +} diff --git a/wicket/src/main/java/de/thpeetz/kontor/KontorApplication.java b/wicket/src/main/java/de/thpeetz/kontor/KontorApplication.java new file mode 100644 index 0000000..9c39858 --- /dev/null +++ b/wicket/src/main/java/de/thpeetz/kontor/KontorApplication.java @@ -0,0 +1,41 @@ +package de.thpeetz.kontor; + +import org.apache.wicket.csp.CSPDirective; +import org.apache.wicket.csp.CSPDirectiveSrcValue; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.protocol.http.WebApplication; + +/** + * Application object for your web application. + * If you want to run this application without deploying, run the Start class. + * + * @see de.thpeetz.kontor.Start#main(String[]) + */ +public class KontorApplication extends WebApplication +{ + /** + * @see org.apache.wicket.Application#getHomePage() + */ + @Override + public Class getHomePage() + { + return HomePage.class; + } + + /** + * @see org.apache.wicket.Application#init() + */ + @Override + public void init() + { + super.init(); + + // needed for the styling used by the quickstart + getCspSettings().blocking() + .add(CSPDirective.STYLE_SRC, CSPDirectiveSrcValue.SELF) + .add(CSPDirective.STYLE_SRC, "https://fonts.googleapis.com/css") + .add(CSPDirective.FONT_SRC, "https://fonts.gstatic.com"); + + // add your configuration here + } +} diff --git a/wicket/src/main/resources/de/thpeetz/kontor/HomePage.html b/wicket/src/main/resources/de/thpeetz/kontor/HomePage.html new file mode 100644 index 0000000..4ca32fc --- /dev/null +++ b/wicket/src/main/resources/de/thpeetz/kontor/HomePage.html @@ -0,0 +1,62 @@ + + + + + Apache Wicket Quickstart + + + + +

+ +
+
+

Congratulations!

+

+ Your quick start works! This project is especially useful to + start developing your Wicket application or to create a test + case for a bug report. +

+

Get started

+

+ You can even switch to HTTPS! +

+

+ From here you can start hacking away at your application and + wow your clients: +

+ +

Get help

+

+ We are here to help! +

+ +

Reporting a bug

+

+ Help us help you: +

+
    +
  1. reproduce the bug with the least amount of code
  2. +
  3. create a unit test that shows the bug
  4. +
  5. fix the bug and create a patch
  6. +
  7. attach the result of step 1, 2 or 3 to a JIRA issue
  8. +
  9. profit!
  10. +
+

+ Please mention the correct Wicket version: 1.5-SNAPSHOT. +

+
+
+
+ + diff --git a/wicket/src/main/webapp/WEB-INF/web.xml b/wicket/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..f5b288e --- /dev/null +++ b/wicket/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,32 @@ + + + + kontor-wicket + + + + + wicket.kontor-wicket + org.apache.wicket.protocol.http.WicketFilter + + applicationClassName + de.thpeetz.kontor.KontorApplication + + + + + wicket.kontor-wicket + /* + + diff --git a/wicket/src/main/webapp/logo.png b/wicket/src/main/webapp/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..39ec54854b748ab6aeb6b3965d88f452772d7d8e GIT binary patch literal 12244 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 i1O^!^0gjqN1OFf9qq%!F8)!QK0000`K literal 0 HcmV?d00001 diff --git a/wicket/src/main/webapp/style.css b/wicket/src/main/webapp/style.css new file mode 100644 index 0000000..87576a7 --- /dev/null +++ b/wicket/src/main/webapp/style.css @@ -0,0 +1,68 @@ +body, p, li, a { font-family: georgia, times, serif;font-size:13pt;} +h1, h2, h3 { font-family: 'Yanone Kaffeesatz', arial, serif; } +body { margin:0;padding:0;} +#hd { + width : 100%; + height : 87px; + background-color : #092E67; + margin-top : 0; + padding-top : 10px; + border-bottom : 1px solid #888; + z-index : 0; +} +#ft { + position : absolute; + bottom : 0; + width : 100%; + height : 99px; + background-color : #6493D2; + border-top : 1px solid #888; + z-index : 0; +} +#logo,#bd { + width : 650px; + margin: 0 auto; + padding: 25px 50px 0 50px; +} +#logo h1 { + color : white; + font-size:36pt; + display: inline; +} +#logo img { + display:inline; + vertical-align: bottom; + margin-left : 50px; + margin-right : 5px; +} +body { margin-top : 0; padding-top : 0;} +#logo, #logo h1 { margin-top : 0; padding-top : 0;} +#bd { + position : absolute; + top : 75px; + bottom : 75px; + left : 50%; + margin-left : -325px; + z-index : 1; + overflow: auto; + background-color : #fff; + -webkit-border-radius: 10px; + -moz-border-radius: 10px; + border-radius: 10px; + -moz-box-shadow: 0px 0px 10px #888; + -webkit-box-shadow: 0px 0px 10px #888; + box-shadow: 0px 0px 10px #888; +} +a, a:visited, a:hover, a:active { + color : #6493D2; +} +h2 { + padding : 0; margin:0; + font-size:36pt; + color:#FF5500; +} +h3 { + padding : 0; margin:0; + font-size:24pt; + color:#092E67; +} diff --git a/wicket/src/test/java/de/thpeetz/kontor/Start.java b/wicket/src/test/java/de/thpeetz/kontor/Start.java new file mode 100644 index 0000000..49c740a --- /dev/null +++ b/wicket/src/test/java/de/thpeetz/kontor/Start.java @@ -0,0 +1,106 @@ +package de.thpeetz.kontor; + +import java.lang.management.ManagementFactory; + +import javax.management.MBeanServer; + +import org.eclipse.jetty.jmx.MBeanContainer; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.webapp.WebAppContext; + +/** + * Separate startup class for people that want to run the examples directly. Use parameter + * -Dcom.sun.management.jmxremote to startup JMX (and e.g. connect with jconsole). + */ +public class Start +{ + /** + * Main function, starts the jetty server. + * + * @param args + */ + public static void main(String[] args) + { + System.setProperty("wicket.configuration", "development"); + + Server server = new Server(); + + HttpConfiguration http_config = new HttpConfiguration(); + http_config.setSecureScheme("https"); + http_config.setSecurePort(8443); + http_config.setOutputBufferSize(32768); + + ServerConnector http = new ServerConnector(server, new HttpConnectionFactory(http_config)); + http.setPort(8080); + http.setIdleTimeout(1000 * 60 * 60); + + server.addConnector(http); + + Resource keystore = Resource.newClassPathResource("/keystore.p12"); + if (keystore != null && keystore.exists()) + { + // if a keystore for a SSL certificate is available, start a SSL + // connector on port 8443. + // By default, the quickstart comes with a Apache Wicket Quickstart + // Certificate that expires about half way september 2031. Do not + // use this certificate anywhere important as the passwords are + // available in the source. + + SslContextFactory sslContextFactory = new SslContextFactory(); + sslContextFactory.setKeyStoreResource(keystore); + sslContextFactory.setKeyStorePassword("wicket"); + sslContextFactory.setKeyManagerPassword("wicket"); + + HttpConfiguration https_config = new HttpConfiguration(http_config); + https_config.addCustomizer(new SecureRequestCustomizer()); + + ServerConnector https = new ServerConnector(server, new SslConnectionFactory( + sslContextFactory, "http/1.1"), new HttpConnectionFactory(https_config)); + https.setPort(8443); + https.setIdleTimeout(500000); + + server.addConnector(https); + System.out.println("SSL access to the examples has been enabled on port 8443"); + System.out + .println("You can access the application using SSL on https://localhost:8443"); + System.out.println(); + } + + WebAppContext bb = new WebAppContext(); + bb.setServer(server); + bb.setContextPath("/"); + bb.setWar("src/main/webapp"); + + // uncomment the next two lines if you want to start Jetty with WebSocket (JSR-356) support + // you need org.apache.wicket:wicket-native-websocket-javax in the classpath! + // ServerContainer serverContainer = WebSocketServerContainerInitializer.configureContext(bb); + // serverContainer.addEndpoint(new WicketServerEndpointConfig()); + + // uncomment next line if you want to test with JSESSIONID encoded in the urls + // ((AbstractSessionManager) + // bb.getSessionHandler().getSessionManager()).setUsingCookies(false); + + server.setHandler(bb); + + MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer(); + MBeanContainer mBeanContainer = new MBeanContainer(mBeanServer); + server.addEventListener(mBeanContainer); + server.addBean(mBeanContainer); + + try + { + server.start(); + server.join(); + } catch (Exception e) { + e.printStackTrace(); + System.exit(100); + } + } +} diff --git a/wicket/src/test/java/de/thpeetz/kontor/TestHomePage.java b/wicket/src/test/java/de/thpeetz/kontor/TestHomePage.java new file mode 100644 index 0000000..fe9c409 --- /dev/null +++ b/wicket/src/test/java/de/thpeetz/kontor/TestHomePage.java @@ -0,0 +1,29 @@ +package de.thpeetz.kontor; + +import org.apache.wicket.util.tester.WicketTester; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Simple test using the WicketTester + */ +public class TestHomePage +{ + private WicketTester tester; + + @BeforeEach + public void setUp() + { + tester = new WicketTester(new KontorApplication()); + } + + @Test + public void homepageRendersSuccessfully() + { + //start and render the test page + tester.startPage(HomePage.class); + + //assert rendered page class + tester.assertRenderedPage(HomePage.class); + } +} diff --git a/wicket/src/test/resources/jetty/jetty-http.xml b/wicket/src/test/resources/jetty/jetty-http.xml new file mode 100644 index 0000000..7b39acb --- /dev/null +++ b/wicket/src/test/resources/jetty/jetty-http.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wicket/src/test/resources/jetty/jetty-https.xml b/wicket/src/test/resources/jetty/jetty-https.xml new file mode 100644 index 0000000..35100e7 --- /dev/null +++ b/wicket/src/test/resources/jetty/jetty-https.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + http/1.1 + + + + + + + + + + + + + 30000 + + + + diff --git a/wicket/src/test/resources/jetty/jetty-ssl.xml b/wicket/src/test/resources/jetty/jetty-ssl.xml new file mode 100644 index 0000000..f23231b --- /dev/null +++ b/wicket/src/test/resources/jetty/jetty-ssl.xml @@ -0,0 +1,57 @@ + + + + + + + + + / + + + + + + SSL_RSA_WITH_DES_CBC_SHA + SSL_DHE_RSA_WITH_DES_CBC_SHA + SSL_DHE_DSS_WITH_DES_CBC_SHA + SSL_RSA_EXPORT_WITH_RC4_40_MD5 + SSL_RSA_EXPORT_WITH_DES40_CBC_SHA + SSL_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA + SSL_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA + TLS_RSA_WITH_AES_256_GCM_SHA384 + TLS_RSA_WITH_AES_128_GCM_SHA256 + TLS_RSA_WITH_AES_256_CBC_SHA256 + TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA + TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA + TLS_RSA_WITH_AES_256_CBC_SHA + TLS_RSA_WITH_AES_256_CBC_SHA + TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA + TLS_ECDH_RSA_WITH_AES_256_CBC_SHA + TLS_DHE_RSA_WITH_AES_256_CBC_SHA + TLS_DHE_DSS_WITH_AES_256_CBC_SHA + TLS_RSA_WITH_AES_128_CBC_SHA256 + TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA + TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA + TLS_RSA_WITH_AES_128_CBC_SHA + TLS_RSA_WITH_AES_128_CBC_SHA + TLS_RSA_WITH_AES_128_CBC_SHA + TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA + TLS_ECDH_RSA_WITH_AES_128_CBC_SHA + TLS_DHE_RSA_WITH_AES_128_CBC_SHA + TLS_DHE_DSS_WITH_AES_128_CBC_SHA + + + + + + + + + + + + + + + diff --git a/wicket/src/test/resources/jetty/jetty.xml b/wicket/src/test/resources/jetty/jetty.xml new file mode 100644 index 0000000..5590715 --- /dev/null +++ b/wicket/src/test/resources/jetty/jetty.xml @@ -0,0 +1,23 @@ + + + + + + + + https + + + + 32768 + 8192 + 8192 + true + false + 512 + + + + + + diff --git a/wicket/src/test/resources/keystore b/wicket/src/test/resources/keystore new file mode 100644 index 0000000000000000000000000000000000000000..1473db33287b4de9be58dc79741ac9d5c088919a GIT binary patch literal 3954 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 e{K-i+ON5SRjUU_1uu~kDP_^p8r(#`g+`j Date: Thu, 9 Jan 2025 22:28:46 +0100 Subject: [PATCH 18/26] add repo kontor-quarkus --- java-quarkus/.gitignore | 9 + java-quarkus/.gitlab-ci.yml | 38 +++ java-quarkus/README.md | 60 ++++ java-quarkus/build.gradle | 158 ++++++++++ java-quarkus/config/checkstyle/checkstyle.xml | 14 + java-quarkus/gradle.properties | 9 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 58910 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + java-quarkus/gradlew | 185 ++++++++++++ java-quarkus/gradlew.bat | 104 +++++++ java-quarkus/settings.gradle | 11 + .../src/docs/asciidoc/kontor-quarkus.adoc | 84 ++++++ java-quarkus/src/main/docker/Dockerfile.jvm | 94 ++++++ .../src/main/docker/Dockerfile.legacy-jar | 90 ++++++ .../src/main/docker/Dockerfile.native | 27 ++ .../src/main/docker/Dockerfile.native-micro | 30 ++ .../thpeetz/kontor/comics/ComicsResource.java | 95 ++++++ .../kontor/comics/service/ArtistService.java | 28 ++ .../kontor/comics/service/ComicsService.java | 12 + .../comics/service/PublisherService.java | 28 ++ .../resources/META-INF/resources/index.html | 284 ++++++++++++++++++ .../src/main/resources/application.properties | 3 + .../kontor/comics/ComicsResourceIT.java | 8 + .../kontor/comics/ComicsResourceTest.java | 19 ++ 24 files changed, 1395 insertions(+) create mode 100644 java-quarkus/.gitignore create mode 100644 java-quarkus/.gitlab-ci.yml create mode 100644 java-quarkus/README.md create mode 100644 java-quarkus/build.gradle create mode 100644 java-quarkus/config/checkstyle/checkstyle.xml create mode 100644 java-quarkus/gradle.properties create mode 100644 java-quarkus/gradle/wrapper/gradle-wrapper.jar create mode 100644 java-quarkus/gradle/wrapper/gradle-wrapper.properties create mode 100644 java-quarkus/gradlew create mode 100644 java-quarkus/gradlew.bat create mode 100644 java-quarkus/settings.gradle create mode 100644 java-quarkus/src/docs/asciidoc/kontor-quarkus.adoc create mode 100644 java-quarkus/src/main/docker/Dockerfile.jvm create mode 100644 java-quarkus/src/main/docker/Dockerfile.legacy-jar create mode 100644 java-quarkus/src/main/docker/Dockerfile.native create mode 100644 java-quarkus/src/main/docker/Dockerfile.native-micro create mode 100644 java-quarkus/src/main/java/de/thpeetz/kontor/comics/ComicsResource.java create mode 100644 java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ArtistService.java create mode 100644 java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ComicsService.java create mode 100644 java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/PublisherService.java create mode 100644 java-quarkus/src/main/resources/META-INF/resources/index.html create mode 100644 java-quarkus/src/main/resources/application.properties create mode 100644 java-quarkus/src/native-test/java/de/thpeetz/kontor/comics/ComicsResourceIT.java create mode 100644 java-quarkus/src/test/java/de/thpeetz/kontor/comics/ComicsResourceTest.java diff --git a/java-quarkus/.gitignore b/java-quarkus/.gitignore new file mode 100644 index 0000000..cec4d77 --- /dev/null +++ b/java-quarkus/.gitignore @@ -0,0 +1,9 @@ +build/ +.settings/ +.gradle/ +.classpath +.project +.vscode/ +bin/ +.idea/ +/.eclipse-pmd diff --git a/java-quarkus/.gitlab-ci.yml b/java-quarkus/.gitlab-ci.yml new file mode 100644 index 0000000..ed4caf6 --- /dev/null +++ b/java-quarkus/.gitlab-ci.yml @@ -0,0 +1,38 @@ +variables: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" + +before_script: + - source "/home/gitlab-runner/.sdkman/bin/sdkman-init.sh" + - sdk u java 11.0.12-open + - chmod +x ./gradlew + +stages: + - build + - test + - analysis + - publish + +Build Application: + stage: build + script: ./gradlew --build-cache compileJava + +Create Documentation: + stage: build + script: ./gradlew asciidoctorPdf + +Test Application: + stage: test + script: ./gradlew check + +sonarqube-check: + stage: analysis + variables: + SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" # Defines the location of the analysis task cache + GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task + script: ./gradlew sonarqube + allow_failure: true + +Publish Artifacts: + stage: publish + script: ./gradlew publish + diff --git a/java-quarkus/README.md b/java-quarkus/README.md new file mode 100644 index 0000000..11ec472 --- /dev/null +++ b/java-quarkus/README.md @@ -0,0 +1,60 @@ +# kontor-quarkus Project + +This project uses Quarkus, the Supersonic Subatomic Java Framework. + +If you want to learn more about Quarkus, please visit its website: https://quarkus.io/ . + +## Running the application in dev mode + +You can run your application in dev mode that enables live coding using: +```shell script +./gradlew quarkusDev +``` + +> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/. + +## Packaging and running the application + +The application can be packaged using: +```shell script +./gradlew build +``` +It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `build/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar build/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: +```shell script +./gradlew build -Dquarkus.package.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar build/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: +```shell script +./gradlew build -Dquarkus.package.type=native +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: +```shell script +./gradlew build -Dquarkus.package.type=native -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./build/kontor-quarkus-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult https://quarkus.io/guides/gradle-tooling. + +## Related Guides + +- RESTEasy Reactive ([guide](https://quarkus.io/guides/resteasy-reactive)): A JAX-RS implementation utilizing build time processing and Vert.x. This extension is not compatible with the quarkus-resteasy extension, or any of the extensions that depend on it. + +## Provided Code + +### RESTEasy Reactive + +Easily start your Reactive RESTful Web Services + +[Related guide section...](https://quarkus.io/guides/getting-started-reactive#reactive-jax-rs-resources) diff --git a/java-quarkus/build.gradle b/java-quarkus/build.gradle new file mode 100644 index 0000000..57e8102 --- /dev/null +++ b/java-quarkus/build.gradle @@ -0,0 +1,158 @@ +plugins { + id 'java' + id 'maven-publish' + id 'jacoco' + id 'jacoco-report-aggregation' + id 'pmd' + id 'checkstyle' + id 'jvm-test-suite' + id 'io.quarkus' + id "org.sonarqube" version "3.3" + alias(versionsLibs.plugins.asciidoctorConvert) + alias(versionsLibs.plugins.asciidoctorPdf) + alias(versionsLibs.plugins.asciidoctorGems) +} + +repositories { + ruby.gems() + maven { + url "https://nexus.thpeetz.de/nexus/content/groups/public/" + } + mavenCentral() + mavenLocal() +} + +dependencies { + implementation enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}") + implementation 'io.quarkus:quarkus-resteasy-reactive' + implementation 'io.quarkus:quarkus-resteasy-reactive-jackson' + implementation 'io.quarkus:quarkus-arc' + testImplementation 'io.quarkus:quarkus-junit5' + testImplementation 'io.rest-assured:rest-assured' + testImplementation("io.quarkus:quarkus-jacoco") +} + +final BUILD_DATE = new Date().format('dd.MM.yyyy').toString() +def pdfFile = layout.buildDirectory.file("docs/asciidocPdf/" + project.name + ".pdf") +def pdfArtifact = artifacts.add('archives', pdfFile.get().asFile) { + type 'pdf' + builtBy asciidoctorPdf +} + +tasks.withType(GenerateModuleMetadata).configureEach { + suppressedValidationErrors.add('enforced-platform') +} + +publishing { + publications { + quarkusApp(MavenPublication){ + from components.java + } + docs(MavenPublication) { + groupId = group + '.docs' + artifactId = project.name + artifact pdfArtifact + } + } + repositories { + maven { + name = 'nexusPeetz' + def releasesRepoUrl = "https://nexus.thpeetz.de/nexus/content/repositories/releases/" + def snapshotsRepoUrl = "https://nexus.thpeetz.de/nexus/content/repositories/snapshots/" + url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl + credentials(PasswordCredentials) + } + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +tasks.withType(GenerateModuleMetadata).configureEach { + suppressedValidationErrors.add('enforced-platform') +} + +compileJava { + options.encoding = 'UTF-8' + options.compilerArgs << '-parameters' +} + +compileTestJava { + options.encoding = 'UTF-8' +} + +jacocoTestReport { + reports { + xml.required = true + } +} + +test.finalizedBy jacocoTestReport + + +pmd { + //ruleSetFiles = files("custom-pmd-ruleset.xml") + ruleSets = ["category/java/errorprone.xml", "category/java/bestpractices.xml"] + ignoreFailures = true +} + +tasks.withType(Checkstyle) { + reports { + xml.required = true + html.required = true + } +} + +testing { + suites { + test { + useJUnitJupiter() + } + } +} + +pmdTest.dependsOn("compileQuarkusTestGeneratedSourcesJava") +checkstyleTest.dependsOn("compileQuarkusTestGeneratedSourcesJava") +jacocoTestReport.dependsOn("compileQuarkusGeneratedSourcesJava") +pmdMain.dependsOn("compileQuarkusGeneratedSourcesJava") + +sonarqube { + properties { + property "sonar.host.url", "https://sonar.thpeetz.de" + property "sonar.qualitygate.wait", true + property "sonar.sourceEncoding", "UTF-8" + property "sonar.projectKey", "kontor_kontor-quarkus_AYQek8Mxz0hBjLSV8I8O" + property "sonar.login", "5ecd90dee57806857e07443a9b0efd3cd7774a81" + } +} + +tasks.named('sonarqube').configure { + dependsOn 'test', 'pmdMain', 'checkstyleMain', 'jacocoTestReport' +} + +asciidoctorPdf { + baseDirFollowsSourceFile() + + asciidoctorj { + attributes 'build-gradle': file('build.gradle'), + 'endpoint-url': 'https://www.thpeetz.de', + 'source-highlighter': 'rouge', + 'imagesdir': './images', + 'toc': 'left', + 'toc-title': 'Inhaltsverzeichnis', + 'revdate': BUILD_DATE, + 'revnumber': { project.version.toString() }, + 'revremark': 'Entwurf', + 'chapter-label': '', + 'icons': 'font', + 'idprefix': 'id_', + 'idseparator': '-', + 'docinfo1': '' + } +} + +wrapper { + gradleVersion = "7.5" +} diff --git a/java-quarkus/config/checkstyle/checkstyle.xml b/java-quarkus/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..2574b06 --- /dev/null +++ b/java-quarkus/config/checkstyle/checkstyle.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/java-quarkus/gradle.properties b/java-quarkus/gradle.properties new file mode 100644 index 0000000..c30b39f --- /dev/null +++ b/java-quarkus/gradle.properties @@ -0,0 +1,9 @@ +#Gradle properties +group=de.thpeetz.kontor +version=1.0.0-SNAPSHOT + +quarkusPluginId=io.quarkus +quarkusPluginVersion=2.13.2.Final +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=2.13.2.Final \ No newline at end of file diff --git a/java-quarkus/gradle/wrapper/gradle-wrapper.jar b/java-quarkus/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..62d4c053550b91381bbd28b1afc82d634bf73a8a GIT binary patch literal 58910 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 literal 0 HcmV?d00001 diff --git a/java-quarkus/gradle/wrapper/gradle-wrapper.properties b/java-quarkus/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/java-quarkus/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/java-quarkus/gradlew b/java-quarkus/gradlew new file mode 100644 index 0000000..fbd7c51 --- /dev/null +++ b/java-quarkus/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/java-quarkus/gradlew.bat b/java-quarkus/gradlew.bat new file mode 100644 index 0000000..a9f778a --- /dev/null +++ b/java-quarkus/gradlew.bat @@ -0,0 +1,104 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/java-quarkus/settings.gradle b/java-quarkus/settings.gradle new file mode 100644 index 0000000..0ab2682 --- /dev/null +++ b/java-quarkus/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + } + plugins { + id "${quarkusPluginId}" version "${quarkusPluginVersion}" + } +} +rootProject.name='kontor-quarkus' diff --git a/java-quarkus/src/docs/asciidoc/kontor-quarkus.adoc b/java-quarkus/src/docs/asciidoc/kontor-quarkus.adoc new file mode 100644 index 0000000..d5d7ac4 --- /dev/null +++ b/java-quarkus/src/docs/asciidoc/kontor-quarkus.adoc @@ -0,0 +1,84 @@ += Projektbeschreibung kontor-quarkus: Pflichtenheft und Projekthandbuch +:author: Thomas Peetz +:email: +:doctype: article +:sectnums: +:sectnumlevels: 4 +:toc: +:toclevels: 4 +:table-caption!: +:counter: table-number: 0 + +//[title="Dokumenthistorie", caption="Tabelle {counter:table-number} ", id="Tabelle-{counter:table-number}", options="header"] +//[title="Dokumenthistorie", id="Table-{counter:table-number}", options="header", cols="4"] +[title="Dokumenthistorie", id="Table-{counter:table-number}", options="header"] +|=== +| Version | Datum | Autor | Änderungsgrund / Bemerkungen +| 0.0.1 | 16.05.2022 | Thomas Peetz | Ersterstellung +|=== + +== Einführung + +=== Zweck + +=== Stakeholder des Systems + +=== Systemumfang + +==== Zielsetzung des Systems + +=== Systemübersicht + +==== Systemkontext + +==== Systemarchitektur + +==== Systemschnittstellen + +===== Realisierte Schnittstellen + +===== Verwendete Schnittstellen + +==== Logisches Datenmodell + +==== Einschränkungen + +== Anforderungen der Domäne + +=== Systemfunktionen + +==== Anwendungsfälle + +==== Akteure + +==== Zielgruppen + +=== Anforderungen + +==== Anforderungen an externe Schnittstellen + +==== Funktionale Anforderungen + +==== Qualitätsanforderungen + +==== Randbedingungen + +==== Weitere Anforderungen + +==== Wartungs- und Supportinformationen + +=== Verifikation + +[bibliography] +== Referenzen + +[glossary] +== Glossar + +== Verzeichnisse + +=== Abbildungsverzeichnis + +=== Tabellenverzeichnis + +<> <> diff --git a/java-quarkus/src/main/docker/Dockerfile.jvm b/java-quarkus/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..476b644 --- /dev/null +++ b/java-quarkus/src/main/docker/Dockerfile.jvm @@ -0,0 +1,94 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/kontor-quarkus-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-quarkus-jvm +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5005 +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-quarkus-jvm +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-11:1.14 + +ENV LANGUAGE='en_US:en' + + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 build/quarkus-app/*.jar /deployments/ +COPY --chown=185 build/quarkus-app/app/ /deployments/app/ +COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 8080 +USER 185 +ENV AB_JOLOKIA_OFF="" +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + diff --git a/java-quarkus/src/main/docker/Dockerfile.legacy-jar b/java-quarkus/src/main/docker/Dockerfile.legacy-jar new file mode 100644 index 0000000..8f309c6 --- /dev/null +++ b/java-quarkus/src/main/docker/Dockerfile.legacy-jar @@ -0,0 +1,90 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=legacy-jar +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/kontor-quarkus-legacy-jar . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-quarkus-legacy-jar +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5005 +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-quarkus-legacy-jar +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-11:1.14 + +ENV LANGUAGE='en_US:en' + + +COPY build/lib/* /deployments/lib/ +COPY build/*-runner.jar /deployments/quarkus-run.jar + +EXPOSE 8080 +USER 185 +ENV AB_JOLOKIA_OFF="" +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" diff --git a/java-quarkus/src/main/docker/Dockerfile.native b/java-quarkus/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..d5a28b2 --- /dev/null +++ b/java-quarkus/src/main/docker/Dockerfile.native @@ -0,0 +1,27 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=native +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t quarkus/kontor-quarkus . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-quarkus +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.6 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/java-quarkus/src/main/docker/Dockerfile.native-micro b/java-quarkus/src/main/docker/Dockerfile.native-micro new file mode 100644 index 0000000..55df6f9 --- /dev/null +++ b/java-quarkus/src/main/docker/Dockerfile.native-micro @@ -0,0 +1,30 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# It uses a micro base image, tuned for Quarkus native executables. +# It reduces the size of the resulting container image. +# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=native +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/kontor-quarkus . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/kontor-quarkus +# +### +FROM quay.io/quarkus/quarkus-micro-image:1.0 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/java-quarkus/src/main/java/de/thpeetz/kontor/comics/ComicsResource.java b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/ComicsResource.java new file mode 100644 index 0000000..f6518b9 --- /dev/null +++ b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/ComicsResource.java @@ -0,0 +1,95 @@ +package de.thpeetz.kontor.comics; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import de.thpeetz.kontor.comics.service.ArtistService; +import de.thpeetz.kontor.comics.service.ComicsService; +import de.thpeetz.kontor.comics.service.PublisherService; + +@Path("/kontor/comics") +public class ComicsResource { + + @Inject + transient ComicsService comicsService; + + @Inject + transient ArtistService artistService; + + @Inject + transient PublisherService publisherService; + + @GET + @Produces(MediaType.TEXT_HTML) + @Path("/") + public String showComicList() { + return comicsService.showComicList(); + } + + @GET + @Produces(MediaType.TEXT_HTML) + @Path("/artist") + public String showArtistList() { + return artistService.showArtistList(); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/artist") + public String showArtistListJson() { + return artistService.showArtistList(); + } + + @GET + @Produces(MediaType.TEXT_HTML) + @Path("/artist/view/{artist_id}") + public String showArtist(String artist_id) { + return artistService.showArtist(artist_id); + } + + @GET + @Produces(MediaType.TEXT_HTML) + @Path("/artist/create") + public String showArtistCreation() { + return artistService.showArtistCreation(); + } + + @POST + @Produces(MediaType.TEXT_HTML) + @Path("/artist/create") + public String validateArtistDetails() { + return artistService.validateArtistDetails(); + } + + @GET + @Produces(MediaType.TEXT_HTML) + @Path("/publisher") + public String showPublisherList() { + return publisherService.showPublisherList(); + } + + @GET + @Produces(MediaType.TEXT_HTML) + @Path("/publisher/view/{publisher_id}") + public String showPublisher(String publisher_id) { + return publisherService.showPublisher(publisher_id); + } + + @GET + @Produces(MediaType.TEXT_HTML) + @Path("/publisher/create") + public String showPublisherCreation() { + return publisherService.showPublisherCreation(); + } + + @POST + @Produces(MediaType.TEXT_HTML) + @Path("/publisher/create") + public String validatePublisherDetails() { + return publisherService.validatePublisherDetails(); + } +} diff --git a/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ArtistService.java b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ArtistService.java new file mode 100644 index 0000000..cdef9aa --- /dev/null +++ b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ArtistService.java @@ -0,0 +1,28 @@ +package de.thpeetz.kontor.comics.service; + +import javax.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class ArtistService { + + public String showArtistList() { + // TODO Auto-generated method stub + return null; + } + + public String showArtist(String artist_id) { + // TODO Auto-generated method stub + return null; + } + + public String showArtistCreation() { + // TODO Auto-generated method stub + return null; + } + + public String validateArtistDetails() { + // TODO Auto-generated method stub + return null; + } + +} diff --git a/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ComicsService.java b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ComicsService.java new file mode 100644 index 0000000..ee58e17 --- /dev/null +++ b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/ComicsService.java @@ -0,0 +1,12 @@ +package de.thpeetz.kontor.comics.service; + +import javax.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class ComicsService { + + public String showComicList() { + return "comics"; + } + +} diff --git a/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/PublisherService.java b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/PublisherService.java new file mode 100644 index 0000000..d3414b1 --- /dev/null +++ b/java-quarkus/src/main/java/de/thpeetz/kontor/comics/service/PublisherService.java @@ -0,0 +1,28 @@ +package de.thpeetz.kontor.comics.service; + +import javax.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class PublisherService { + + public String showPublisherList() { + // TODO Auto-generated method stub + return null; + } + + public String showPublisher(String publisher_id) { + // TODO Auto-generated method stub + return null; + } + + public String showPublisherCreation() { + // TODO Auto-generated method stub + return null; + } + + public String validatePublisherDetails() { + // TODO Auto-generated method stub + return null; + } + +} diff --git a/java-quarkus/src/main/resources/META-INF/resources/index.html b/java-quarkus/src/main/resources/META-INF/resources/index.html new file mode 100644 index 0000000..609ae3a --- /dev/null +++ b/java-quarkus/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,284 @@ + + + + + kontor-quarkus - 1.0.0-SNAPSHOT + + + +
+
+
+ + + + + quarkus_logo_horizontal_rgb_1280px_reverse + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+
+

You just made a Quarkus application.

+

This page is served by Quarkus.

+ Visit the Dev UI +

This page: src/main/resources/META-INF/resources/index.html

+

App configuration: src/main/resources/application.properties

+

Static assets: src/main/resources/META-INF/resources/

+

Code: src/main/java

+

Generated starter code:

+
    +
  • + RESTEasy Reactive Easily start your Reactive RESTful Web Services +
    @Path: /hello +
    Related guide +
  • + +
+
+
+

Selected extensions

+
    +
  • RESTEasy Reactive (guide)
  • +
  • RESTEasy Reactive Jackson
  • +
+
Documentation
+

Practical step-by-step guides to help you achieve a specific goal. Use them to help get your work + done.

+
Set up your IDE
+

Everyone has a favorite IDE they like to use to code. Learn how to configure yours to maximize your + Quarkus productivity.

+
+
+
+ + diff --git a/java-quarkus/src/main/resources/application.properties b/java-quarkus/src/main/resources/application.properties new file mode 100644 index 0000000..21328a1 --- /dev/null +++ b/java-quarkus/src/main/resources/application.properties @@ -0,0 +1,3 @@ +# Your configuration properties +quarkus.http.test-port=8083 +quarkus.http.test-ssl-port=8446 diff --git a/java-quarkus/src/native-test/java/de/thpeetz/kontor/comics/ComicsResourceIT.java b/java-quarkus/src/native-test/java/de/thpeetz/kontor/comics/ComicsResourceIT.java new file mode 100644 index 0000000..230d951 --- /dev/null +++ b/java-quarkus/src/native-test/java/de/thpeetz/kontor/comics/ComicsResourceIT.java @@ -0,0 +1,8 @@ +package de.thpeetz.kontor.comics; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class ComicsResourceIT extends ComicsResourceTest { + +} diff --git a/java-quarkus/src/test/java/de/thpeetz/kontor/comics/ComicsResourceTest.java b/java-quarkus/src/test/java/de/thpeetz/kontor/comics/ComicsResourceTest.java new file mode 100644 index 0000000..25dcd49 --- /dev/null +++ b/java-quarkus/src/test/java/de/thpeetz/kontor/comics/ComicsResourceTest.java @@ -0,0 +1,19 @@ +package de.thpeetz.kontor.comics; + +import org.junit.jupiter.api.Test; +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +class ComicsResourceTest { + + @Test + void testComicsEndpoint() { + given().when().get("/kontor/comics") + .then() + .statusCode(200) + .body(is("comics")); + } +} From 2ae11e24eff04d6938234a00f609a4e4bd1a8ce1 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Fri, 10 Jan 2025 17:39:54 +0100 Subject: [PATCH 19/26] add sqlalchemy as orm tool --- gui/comic_model.py | 89 -------------------------- gui/data.py | 79 ----------------------- gui/database/__init__.py | 134 +++++++++++++++++++++++++++++++++++++++ gui/database/base.py | 7 ++ gui/database/comic.py | 40 ++++++++++++ gui/database/media.py | 25 ++++++++ gui/database/metadata.py | 49 ++++++++++++++ gui/kontor.py | 3 +- gui/model_config.py | 7 +- gui/table_model.py | 20 ++++++ 10 files changed, 281 insertions(+), 172 deletions(-) delete mode 100644 gui/comic_model.py delete mode 100644 gui/data.py create mode 100644 gui/database/__init__.py create mode 100644 gui/database/base.py create mode 100644 gui/database/comic.py create mode 100644 gui/database/media.py create mode 100644 gui/database/metadata.py diff --git a/gui/comic_model.py b/gui/comic_model.py deleted file mode 100644 index 2472670..0000000 --- a/gui/comic_model.py +++ /dev/null @@ -1,89 +0,0 @@ -from datetime import datetime - -import mariadb -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt -from PySide6.QtGui import QColor - - -class ComicTableModel(QAbstractTableModel): - - def __init__(self, db_config, main_window): - super().__init__() - self.main_window = main_window - self._data = [] - self.status_bar = main_window.statusBar - self.mariadb_conn = mariadb.connect( - host=db_config['mariadb']['host'], - port=db_config['mariadb']['port'], - user=db_config['mariadb']['user'], - password=db_config['mariadb']['password'], - database=db_config['mariadb']['database'] - ) - self.refresh() - - def refresh(self): - data = [] - cursor = self.mariadb_conn.cursor() - cursor.execute("SELECT id, created_date, last_modified_date, title, publisher_id FROM comic") - rows = cursor.fetchall() - for row in rows: - data.append(list(row)) - self.status_bar.showMessage(f"{len(rows)} Einträge geladen", 3000) - self._data = data - - def rowCount(self, parent=QModelIndex()): - # The length of the outer list. - return len(self._data) - - def headerData(self, col, orientation, role=Qt.ItemDataRole.DisplayRole): - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - match col: - case 0: - return "ID" - case 1: - return "Created" - case 2: - return "Updated" - case 3: - return "Title" - case 4: - return "Verlag" - if orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole: - return str(col + 1) - - def data(self, index, role=Qt.ItemDataRole.DisplayRole): - value = self._data[index.row()][index.column()] - if role == Qt.ItemDataRole.DisplayRole: - 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 "True" - return "False" - return 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: - 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) - return len(self._data[0]) - - def setData(self, index, value, role=Qt.ItemDataRole.EditRole): - if role == Qt.ItemDataRole.EditRole: - self._data[index.row()][index.column()] = value - if role == Qt.ItemDataRole.CheckStateRole: - checked = value == Qt.CheckState.Checked - self._data[index.row()][index.column()] = checked - return True - - def flags(self, index): - return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsUserTristate diff --git a/gui/data.py b/gui/data.py deleted file mode 100644 index bb4436c..0000000 --- a/gui/data.py +++ /dev/null @@ -1,79 +0,0 @@ -import mariadb - - -class KontorDB: - - def __init__(self, db_config): - self.db_conn = mariadb.connect( - host=db_config['mariadb']['host'], - port=db_config['mariadb']['port'], - user=db_config['mariadb']['user'], - password=db_config['mariadb']['password'], - database=db_config['mariadb']['database'] - ) - - def get_table_id(self, table_name): - cursor = self.db_conn.cursor() - cursor.execute("SELECT id, created_date, last_modified_date FROM meta_data_table WHERE table_name=?", (table_name, )) - row = cursor.fetchone() - cursor.close() - return row[0] - - def get_table_names(self) -> list: - tables_names = [] - cursor = self.db_conn.cursor() - cursor.execute("SELECT id, table_name from meta_data_table") - rows = cursor.fetchall() - for (_, table_name) in rows: - tables_names.append(table_name) - cursor.close() - return tables_names - - def get_column_meta_data(self, table_id): - cursor = self.db_conn.cursor() - meta_data = {} - cursor.execute("SELECT column_name, column_order, column_label FROM meta_data_column WHERE table_id=? AND is_shown is true ORDER bY column_order", (table_id, )) - rows = cursor.fetchall() - order = 0 - for (column_name, column_order, column_label) in rows: - meta_data[order] = { 'column': column_name, 'label': column_label, 'order': column_order} - order += 1 - cursor.close() - # print(f"retrieved {len(rows)} columns, set {len(meta_data)} headers") - 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_data(self, table_name: str, columns: dict, where_clause: str) -> list: - data = [] - cursor = self.db_conn.cursor() - cursor.execute(self.get_statement(table_name, columns, where_clause)) - rows = cursor.fetchall() - print(len(rows)) - for row in rows: - # print(f"KontorDB.get_data: {row}") - data.append(list(row)) - cursor.close() - print(f"KontorDB.getData: return {len(data)}") - return data - - def get_statement(self, table: str, header: dict, where_clause): - columns = "" - for index, column in header.items(): - if index > 0: - columns += ", " - columns += column['column'] - if len(columns) == 0: - columns = "*" - statement = f"SELECT {columns} FROM {table} {where_clause}" - print(f"{statement=}") - return statement diff --git a/gui/database/__init__.py b/gui/database/__init__.py new file mode 100644 index 0000000..122959e --- /dev/null +++ b/gui/database/__init__.py @@ -0,0 +1,134 @@ +import mariadb +from sqlalchemy import create_engine, 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 + + +class KontorDB: + + def __init__(self, db_config): + self.db_conn = mariadb.connect( + host=db_config['mariadb']['host'], + port=db_config['mariadb']['port'], + user=db_config['mariadb']['user'], + password=db_config['mariadb']['password'], + database=db_config['mariadb']['database'] + ) + connect_string = ('mariadb+mariadbconnector://{}:{}@{}:{}/{}' + .format( + db_config['mariadb']['user'], + db_config['mariadb']['password'], + db_config['mariadb']['host'], + db_config['mariadb']['port'], + db_config['mariadb']['database'])) + # engine = create_engine(connect_string, echo=True) + engine = create_engine(connect_string) + Base.metadata.create_all(bind=engine) + __session__ = sessionmaker(bind=engine) + self.session = __session__() + + def get_table_id(self, table_name): + cursor = self.db_conn.cursor() + cursor.execute("SELECT id, created_date, last_modified_date FROM meta_data_table WHERE table_name=?", + (table_name,)) + row = cursor.fetchone() + cursor.close() + # table_ids = self.session.query(MetaDataTable).filter(MetaDataTable.table_name == table_name).all() + # print(type(table_ids)) + return row[0] + + def get_table_names(self) -> list: + tables_names = [] + cursor = self.db_conn.cursor() + cursor.execute("SELECT id, table_name from meta_data_table") + rows = cursor.fetchall().all + for row in rows: + print(row) + for (_, table_name) in rows: + tables_names.append(table_name) + cursor.close() + tables = self.session.query(MetaDataTable).all() + for table in tables: + print(table) + return tables_names + + def get_column_meta_data(self, table_id: str, table_name: str): + cursor = self.db_conn.cursor() + meta_data = {} + cursor.execute( + "SELECT column_name, column_order, column_label FROM meta_data_column WHERE table_id=? AND is_shown is true ORDER bY column_order", + (table_id,)) + rows = cursor.fetchall() + order = 0 + for (column_name, column_order, column_label) in rows: + meta_data[order] = {'column': column_name, 'label': column_label, 'order': column_order} + order += 1 + cursor.close() + columns = (self.session.query(MetaDataColumn). + join(MetaDataTable, MetaDataTable.id == MetaDataColumn.table_id). + filter(MetaDataTable.table_name == table_name). + filter(MetaDataColumn.is_shown is True). + order_by(MetaDataColumn.column_order).all()) + print(columns) + for column in columns: + print(column) + result = repr(column) + if result is not None: + print(result) + # print(f"retrieved {len(rows)} columns, set {len(meta_data)} headers") + 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_data(self, table_name: str, columns: dict, where_clause: str) -> list: + data = [] + cursor = self.db_conn.cursor() + cursor.execute(self.get_statement(table_name, columns, where_clause)) + rows = cursor.fetchall() + print(len(rows)) + for row in rows: + # print(f"KontorDB.get_data: {row}") + data.append(list(row)) + cursor.close() + print(f"KontorDB.getData: return {len(data)}") + if table_name == 'comic' and len(where_clause) == 0: + data.clear() + comics = self.session.query(Comic).all() + for item in comics: + # print(item) + row = [] + for order in columns.keys(): + column_name = columns[order]['column'] + if column_name == 'publisher_id': + row.append(item.publisher.name) + else: + row.append(getattr(item, column_name)) + # print(repr(row)) + data.append(row) + return data + + def get_statement(self, table: str, header: dict, where_clause): + columns = "" + for index, column in header.items(): + if index > 0: + columns += ", " + columns += column['column'] + if len(columns) == 0: + columns = "*" + statement = f"SELECT {columns} FROM {table} {where_clause}" + print(f"{statement=}") + return statement diff --git a/gui/database/base.py b/gui/database/base.py new file mode 100644 index 0000000..fb74a49 --- /dev/null +++ b/gui/database/base.py @@ -0,0 +1,7 @@ +from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker + + +class Base(DeclarativeBase): + pass + + diff --git a/gui/database/comic.py b/gui/database/comic.py new file mode 100644 index 0000000..c17244e --- /dev/null +++ b/gui/database/comic.py @@ -0,0 +1,40 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from database.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)) + 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)) + publisher_id = Column(String, ForeignKey('publisher.id')) + publisher = relationship("Publisher", back_populates="comics") + current_order = Column(BIT(1)) + completed = Column(BIT(1)) + + def __repr__(self): + return f'Comic({self.id} {self.version} {self.title} {self.publisher.name})' + + def __str__(self): + return f'{self.title}({self.id})' diff --git a/gui/database/media.py b/gui/database/media.py new file mode 100644 index 0000000..ebebc57 --- /dev/null +++ b/gui/database/media.py @@ -0,0 +1,25 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from database.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(Boolean, default=True) + title = Column(String(255)) + url = Column(String(255)) + should_download = Column(Boolean, default=True) + + def __repr__(self): + return f'MediaFile({self.id} {self.title} {self.title})' + + def __str__(self): + return f'{self.title}({self.id})' diff --git a/gui/database/metadata.py b/gui/database/metadata.py new file mode 100644 index 0000000..8a59633 --- /dev/null +++ b/gui/database/metadata.py @@ -0,0 +1,49 @@ +from sqlalchemy import Column, String, ForeignKey, DateTime, Integer, Boolean +from sqlalchemy.orm import relationship + +from database 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): + print(f'MetaDataTable({self.id} {self.table_name})') + + def __str__(self): + print(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(Boolean) + show_filter = Column(Boolean) + + def __repr__(self): + if self.column_name is None: + print(f'MetaDataColumn({self.id} {self.table.table_name}.__)') + else: + print(f'MetaDataColumn({self.id} {self.table.table_name}.{self.column_name})') + + def __str__(self): + print(f'{self.column_name}({self.id})') + diff --git a/gui/kontor.py b/gui/kontor.py index 30bb3bd..6356bd2 100644 --- a/gui/kontor.py +++ b/gui/kontor.py @@ -10,9 +10,8 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidg from PySide6.QtWidgets import QApplication, QLabel, QMainWindow from platformdirs import PlatformDirs -from comic_model import ComicTableModel +from database import KontorDB from dialogs import ExportKontorDialog, ImportKontorDialog -from data import KontorDB from model_config import KontorModelConfig from table_model import KontorTableModel diff --git a/gui/model_config.py b/gui/model_config.py index ba3e510..abff7da 100644 --- a/gui/model_config.py +++ b/gui/model_config.py @@ -1,7 +1,8 @@ import mariadb from PySide6.QtWidgets import QHBoxLayout, QCheckBox -from data import KontorDB +from database import KontorDB +from database.comic import Comic class KontorModelConfig: @@ -23,7 +24,7 @@ class KontorModelConfig: 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.header = self.kontor_db.get_column_meta_data(self._table_id, self._table) self.filter = self.kontor_db.get_filters(self._table_id) def get_filter(self) -> str: @@ -44,6 +45,8 @@ class KontorModelConfig: def get_data(self) -> list: data = self.kontor_db.get_data(self._table, self.header, self.get_filter()) 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: diff --git a/gui/table_model.py b/gui/table_model.py index a28e1cc..2af11c9 100644 --- a/gui/table_model.py +++ b/gui/table_model.py @@ -56,6 +56,16 @@ class KontorTableModel(QAbstractTableModel): 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 return str(value) if role == Qt.ItemDataRole.DecorationRole: if isinstance(value, bytes): @@ -64,6 +74,16 @@ class KontorTableModel(QAbstractTableModel): 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 From 8f2e99195a58c55ef14d672a4eeb85c066866cfe Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sat, 11 Jan 2025 01:54:05 +0100 Subject: [PATCH 20/26] reorganize files for Qt application --- gui/resources.py | 7 -- qt/.gitignore | 2 + {gui => qt}/database/__init__.py | 50 ++---------- {gui => qt}/database/base.py | 0 {gui => qt}/database/comic.py | 0 {gui => qt}/database/media.py | 0 {gui => qt}/database/metadata.py | 11 ++- qt/gui/__init__.py | 0 {gui => qt/gui}/dialogs.py | 0 gui/kontor.py => qt/gui/main_window.py | 27 +------ {gui => qt/gui}/model_config.py | 1 - {gui => qt/gui}/table_model.py | 2 +- qt/kontor.py | 21 +++++ qt/pysidedeploy.spec | 98 ++++++++++++++++++++++++ {gui => qt}/res/application-export.png | Bin {gui => qt}/res/application-import.png | Bin {gui => qt}/res/arrow-circle-double.png | Bin {gui => qt}/res/cross.png | Bin {gui => qt}/res/tick.png | Bin 19 files changed, 139 insertions(+), 80 deletions(-) delete mode 100644 gui/resources.py create mode 100644 qt/.gitignore rename {gui => qt}/database/__init__.py (63%) rename {gui => qt}/database/base.py (100%) rename {gui => qt}/database/comic.py (100%) rename {gui => qt}/database/media.py (100%) rename {gui => qt}/database/metadata.py (80%) create mode 100644 qt/gui/__init__.py rename {gui => qt/gui}/dialogs.py (100%) rename gui/kontor.py => qt/gui/main_window.py (88%) rename {gui => qt/gui}/model_config.py (98%) rename {gui => qt/gui}/table_model.py (98%) create mode 100644 qt/kontor.py create mode 100755 qt/pysidedeploy.spec rename {gui => qt}/res/application-export.png (100%) rename {gui => qt}/res/application-import.png (100%) rename {gui => qt}/res/arrow-circle-double.png (100%) rename {gui => qt}/res/cross.png (100%) rename {gui => qt}/res/tick.png (100%) diff --git a/gui/resources.py b/gui/resources.py deleted file mode 100644 index c135d07..0000000 --- a/gui/resources.py +++ /dev/null @@ -1,7 +0,0 @@ -from PySide6.QtGui import QIcon - -tick = QIcon('tick.png') -cross = QIcon('cross.png') -import_icon = QIcon("application-import.png") -export_icon = QIcon("application-export.png") -circle_icon = QIcon("arrow-circle-double.png") diff --git a/qt/.gitignore b/qt/.gitignore new file mode 100644 index 0000000..38b154f --- /dev/null +++ b/qt/.gitignore @@ -0,0 +1,2 @@ +deployment/ +kontor.bin diff --git a/gui/database/__init__.py b/qt/database/__init__.py similarity index 63% rename from gui/database/__init__.py rename to qt/database/__init__.py index 122959e..c6d21a1 100644 --- a/gui/database/__init__.py +++ b/qt/database/__init__.py @@ -1,5 +1,5 @@ import mariadb -from sqlalchemy import create_engine, text, MetaData, join +from sqlalchemy import create_engine, select, text, MetaData, join from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker from database.base import Base @@ -31,54 +31,20 @@ class KontorDB: self.session = __session__() def get_table_id(self, table_name): - cursor = self.db_conn.cursor() - cursor.execute("SELECT id, created_date, last_modified_date FROM meta_data_table WHERE table_name=?", - (table_name,)) - row = cursor.fetchone() - cursor.close() - # table_ids = self.session.query(MetaDataTable).filter(MetaDataTable.table_name == table_name).all() - # print(type(table_ids)) - return row[0] + result = self.session.execute(select(MetaDataTable.id).where(MetaDataTable.table_name == table_name)).scalar() + return result def get_table_names(self) -> list: - tables_names = [] - cursor = self.db_conn.cursor() - cursor.execute("SELECT id, table_name from meta_data_table") - rows = cursor.fetchall().all - for row in rows: - print(row) - for (_, table_name) in rows: - tables_names.append(table_name) - cursor.close() tables = self.session.query(MetaDataTable).all() - for table in tables: - print(table) - return tables_names + result = [table.table_name for table in tables] + return result - def get_column_meta_data(self, table_id: str, table_name: str): - cursor = self.db_conn.cursor() + def get_column_meta_data(self, table_id: str, table_name: str) -> dict: meta_data = {} - cursor.execute( - "SELECT column_name, column_order, column_label FROM meta_data_column WHERE table_id=? AND is_shown is true ORDER bY column_order", - (table_id,)) - rows = cursor.fetchall() order = 0 - for (column_name, column_order, column_label) in rows: - meta_data[order] = {'column': column_name, 'label': column_label, 'order': column_order} + 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} order += 1 - cursor.close() - columns = (self.session.query(MetaDataColumn). - join(MetaDataTable, MetaDataTable.id == MetaDataColumn.table_id). - filter(MetaDataTable.table_name == table_name). - filter(MetaDataColumn.is_shown is True). - order_by(MetaDataColumn.column_order).all()) - print(columns) - for column in columns: - print(column) - result = repr(column) - if result is not None: - print(result) - # print(f"retrieved {len(rows)} columns, set {len(meta_data)} headers") return meta_data def get_filters(self, table_id): diff --git a/gui/database/base.py b/qt/database/base.py similarity index 100% rename from gui/database/base.py rename to qt/database/base.py diff --git a/gui/database/comic.py b/qt/database/comic.py similarity index 100% rename from gui/database/comic.py rename to qt/database/comic.py diff --git a/gui/database/media.py b/qt/database/media.py similarity index 100% rename from gui/database/media.py rename to qt/database/media.py diff --git a/gui/database/metadata.py b/qt/database/metadata.py similarity index 80% rename from gui/database/metadata.py rename to qt/database/metadata.py index 8a59633..2fd0af7 100644 --- a/gui/database/metadata.py +++ b/qt/database/metadata.py @@ -14,11 +14,10 @@ class MetaDataTable(Base): table_columns = relationship("MetaDataColumn") def __repr__(self): - print(f'MetaDataTable({self.id} {self.table_name})') + return f'MetaDataTable({self.id} {self.table_name})' def __str__(self): - print(f'{self.table_name}({self.id})') - + return f'{self.table_name}({self.id})' class MetaDataColumn(Base): __tablename__ = 'meta_data_column' @@ -40,10 +39,10 @@ class MetaDataColumn(Base): def __repr__(self): if self.column_name is None: - print(f'MetaDataColumn({self.id} {self.table.table_name}.__)') + return f'MetaDataColumn({self.id} {self.table.table_name}.__)' else: - print(f'MetaDataColumn({self.id} {self.table.table_name}.{self.column_name})') + return f'MetaDataColumn({self.id} {self.table.table_name}.{self.column_name})' def __str__(self): - print(f'{self.column_name}({self.id})') + return f'{self.column_name}({self.id})' diff --git a/qt/gui/__init__.py b/qt/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/dialogs.py b/qt/gui/dialogs.py similarity index 100% rename from gui/dialogs.py rename to qt/gui/dialogs.py diff --git a/gui/kontor.py b/qt/gui/main_window.py similarity index 88% rename from gui/kontor.py rename to qt/gui/main_window.py index 6356bd2..a652906 100644 --- a/gui/kontor.py +++ b/qt/gui/main_window.py @@ -1,19 +1,11 @@ -""" -PyQT6 GUI for Kontor -""" -import sys -from pathlib import Path - -import yaml from PySide6.QtGui import QAction, QIcon from PySide6.QtWidgets import QWidget, QVBoxLayout, QMenu, QMessageBox, QTabWidget, QTableView -from PySide6.QtWidgets import QApplication, QLabel, QMainWindow -from platformdirs import PlatformDirs +from PySide6.QtWidgets import QLabel, QMainWindow from database import KontorDB -from dialogs import ExportKontorDialog, ImportKontorDialog -from model_config import KontorModelConfig -from table_model import KontorTableModel +from gui.dialogs import ExportKontorDialog, ImportKontorDialog +from gui.model_config import KontorModelConfig +from gui.table_model import KontorTableModel class MainWindow(QMainWindow): @@ -137,14 +129,3 @@ class MainWindow(QMainWindow): layout.addWidget(table_view) model.refresh() return data_tab - - -if __name__ == '__main__': - app = QApplication(sys.argv) - dirs = PlatformDirs("kontor") - database_config = Path(dirs.user_config_dir, 'database-config.yaml') - with open(database_config, 'rt') as f: - db_config = yaml.safe_load(f.read()) - window = MainWindow(db_config) - window.show() - app.exec() diff --git a/gui/model_config.py b/qt/gui/model_config.py similarity index 98% rename from gui/model_config.py rename to qt/gui/model_config.py index abff7da..01d3805 100644 --- a/gui/model_config.py +++ b/qt/gui/model_config.py @@ -2,7 +2,6 @@ import mariadb from PySide6.QtWidgets import QHBoxLayout, QCheckBox from database import KontorDB -from database.comic import Comic class KontorModelConfig: diff --git a/gui/table_model.py b/qt/gui/table_model.py similarity index 98% rename from gui/table_model.py rename to qt/gui/table_model.py index 2af11c9..5e80c3b 100644 --- a/gui/table_model.py +++ b/qt/gui/table_model.py @@ -3,7 +3,7 @@ from datetime import datetime from PySide6.QtCore import QAbstractTableModel, QModelIndex from PySide6.QtGui import Qt -from model_config import KontorModelConfig +from gui.model_config import KontorModelConfig class KontorTableModel(QAbstractTableModel): diff --git a/qt/kontor.py b/qt/kontor.py new file mode 100644 index 0000000..2eed73b --- /dev/null +++ b/qt/kontor.py @@ -0,0 +1,21 @@ +""" +PyQT6 GUI for Kontor +""" +import sys +from pathlib import Path +from platformdirs import PlatformDirs +from PySide6.QtWidgets import QApplication +import yaml + +from gui.main_window import MainWindow + + +if __name__ == '__main__': + app = QApplication(sys.argv) + dirs = PlatformDirs("kontor") + database_config = Path(dirs.user_config_dir, 'database-config.yaml') + with open(database_config, 'rt') as f: + db_config = yaml.safe_load(f.read()) + window = MainWindow(db_config) + window.show() + app.exec() diff --git a/qt/pysidedeploy.spec b/qt/pysidedeploy.spec new file mode 100755 index 0000000..8e65745 --- /dev/null +++ b/qt/pysidedeploy.spec @@ -0,0 +1,98 @@ +[app] + +# title of your application +title = kontor + +# project directory. the general assumption is that project_dir is the parent directory +# of input_file +project_dir = /home/tpeetz/projects/kontor/qt + +# source file path +input_file = /home/tpeetz/projects/kontor/qt/kontor.py + +# directory where exec is stored +exec_directory = . + +# path to .pyproject project file +project_file = + +# application icon +icon = /usr/local/lib/python3.11/dist-packages/PySide6/scripts/deploy_lib/pyside_icon.jpg + +[python] + +# python path +python_path = /usr/bin/python3 + +# python packages to install +packages = Nuitka==2.4.8 + +# buildozer = for deploying Android application +android_packages = buildozer==1.5.0,cython==0.29.33 + +[qt] + +# comma separated path to qml files required +# normally all the qml files required by the project are added automatically +qml_files = + +# excluded qml plugin binaries +excluded_qml_plugins = + +# qt modules used. comma separated +modules = Gui,DBus,Core,Widgets + +# qt plugins used by the application +plugins = platformthemes,imageformats,platforms,generic,iconengines,egldeviceintegrations,styles,xcbglintegrations,platforms/darwin,platforminputcontexts,accessiblebridge + +[android] + +# path to pyside wheel +wheel_pyside = + +# path to shiboken wheel +wheel_shiboken = + +# plugins to be copied to libs folder of the packaged application. comma separated +plugins = + +[nuitka] + +# usage description for permissions requested by the app as found in the info.plist file +# of the app bundle +# eg = extra_args = --show-modules --follow-stdlib +macos.permissions = + +# mode of using nuitka. accepts standalone or onefile. default is onefile. +mode = onefile + +# (str) specify any extra nuitka arguments +extra_args = --quiet --noinclude-qt-translations + +[buildozer] + +# build mode +# possible options = [release, debug] +# release creates an aab, while debug creates an apk +mode = debug + +# contrains path to pyside6 and shiboken6 recipe dir +recipe_dir = + +# path to extra qt android jars to be loaded by the application +jars_dir = + +# if empty uses default ndk path downloaded by buildozer +ndk_path = + +# if empty uses default sdk path downloaded by buildozer +sdk_path = + +# other libraries to be loaded. comma separated. +# loaded at app startup +local_libs = + +# architecture of deployed platform +# possible values = ["aarch64", "armv7a", "i686", "x86_64"] +arch = + diff --git a/gui/res/application-export.png b/qt/res/application-export.png similarity index 100% rename from gui/res/application-export.png rename to qt/res/application-export.png diff --git a/gui/res/application-import.png b/qt/res/application-import.png similarity index 100% rename from gui/res/application-import.png rename to qt/res/application-import.png diff --git a/gui/res/arrow-circle-double.png b/qt/res/arrow-circle-double.png similarity index 100% rename from gui/res/arrow-circle-double.png rename to qt/res/arrow-circle-double.png diff --git a/gui/res/cross.png b/qt/res/cross.png similarity index 100% rename from gui/res/cross.png rename to qt/res/cross.png diff --git a/gui/res/tick.png b/qt/res/tick.png similarity index 100% rename from gui/res/tick.png rename to qt/res/tick.png From a3652cc9b8cf471e1a01ff340a28c42241e1830d Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sat, 11 Jan 2025 19:00:58 +0100 Subject: [PATCH 21/26] add tysc schema --- qt/database/base.py | 2 +- qt/database/media.py | 6 +- qt/database/metadata.py | 6 +- qt/database/tysc.py | 68 +++++++++++++++++++ .../de/thpeetz/kontor/tysc/data/Team.java | 1 - 5 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 qt/database/tysc.py diff --git a/qt/database/base.py b/qt/database/base.py index fb74a49..9339e51 100644 --- a/qt/database/base.py +++ b/qt/database/base.py @@ -1,7 +1,7 @@ +from sqlalchemy import Column, String, DateTime, Integer from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker class Base(DeclarativeBase): pass - diff --git a/qt/database/media.py b/qt/database/media.py index ebebc57..621fdce 100644 --- a/qt/database/media.py +++ b/qt/database/media.py @@ -1,5 +1,5 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String -from sqlalchemy.orm import relationship +from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy.dialects.mysql import BIT from database.base import Base @@ -13,7 +13,7 @@ class MediaFile(Base): cloud_link = Column(String(255)) file_name = Column(String(255)) path = Column(String(255)) - review = Column(Boolean, default=True) + review = Column(BIT(1), default=True) title = Column(String(255)) url = Column(String(255)) should_download = Column(Boolean, default=True) diff --git a/qt/database/metadata.py b/qt/database/metadata.py index 2fd0af7..e1b9084 100644 --- a/qt/database/metadata.py +++ b/qt/database/metadata.py @@ -1,4 +1,5 @@ from sqlalchemy import Column, String, ForeignKey, DateTime, Integer, Boolean +from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship from database import Base @@ -34,8 +35,8 @@ class MetaDataColumn(Base): table = relationship("MetaDataTable", back_populates="table_columns") column_label = Column(String(255)) filter_label = Column(String(255)) - is_shown = Column(Boolean) - show_filter = Column(Boolean) + is_shown = Column(BIT(1)) + show_filter = Column(BIT(1)) def __repr__(self): if self.column_name is None: @@ -45,4 +46,3 @@ class MetaDataColumn(Base): def __str__(self): return f'{self.column_name}({self.id})' - diff --git a/qt/database/tysc.py b/qt/database/tysc.py new file mode 100644 index 0000000..e78ff17 --- /dev/null +++ b/qt/database/tysc.py @@ -0,0 +1,68 @@ +from sqlalchemy import Boolean, Column, DateTime, Integer, String, ForeignKey +from sqlalchemy.dialects.mysql import BIT +from sqlalchemy.orm import relationship + +from database.base import Base + + +class Sport(Base): + __tablename__ = "sport" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255)) + 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)) + short_name = Column(String(255)) + sport_id = Column(String, ForeignKey("sport.id")) + sport = relationship("Sport", back_populates="positions") + roosters = relationship("Rooster") + + +class FieldPosition(Base): + __tablename__ = "field_position" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + name = Column(String(255)) + short_name = Column(String(255)) + sport_id = Column(String, ForeignKey("sport.id")) + sport = relationship("Sport", back_populates="positions") + roosters = relationship("Rooster") + + +class Player(Base): + __tablename__ = "player" + id = Column(String, primary_key=True) + created_date = Column(DateTime) + last_modified_date = Column(DateTime) + version = Column(Integer) + first_name = Column(String(255)) + last_name = Column(String(255)) + roosters = relationship("Rooster") + + +class Rooster(Base): + __tablename__ = "rooster" + 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")) + team = relationship("Team", back_populates="roosters") + player_id = Column(String, ForeignKey("player.id")) + player = relationship("Player", back_populates="roosters") + position_id = Column(String, ForeignKey("field_position.id")) + position = relationship("roosters") diff --git a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java index b61fdd2..353da78 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java +++ b/springboot/src/main/java/de/thpeetz/kontor/tysc/data/Team.java @@ -1,7 +1,6 @@ package de.thpeetz.kontor.tysc.data; import java.util.List; -import java.util.Objects; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; From bf14c9a020c466987d67cbb680f52302bc703a2f Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 12 Jan 2025 02:41:40 +0100 Subject: [PATCH 22/26] extend MetaDataColumn by adding column for name of column for referenced tables --- qt/database/__init__.py | 13 ++- qt/database/comic.py | 96 ++++++++++++++++++- qt/database/metadata.py | 1 + qt/database/tysc.py | 89 ++++++++++++++--- qt/gui/main_window.py | 4 + qt/gui/model_config.py | 2 +- .../kontor/admin/SetupModuleAdmin.java | 6 +- .../kontor/admin/data/MetaDataColumn.java | 2 + .../admin/services/MetaDataService.java | 9 ++ .../kontor/admin/views/MetaDataForm.java | 2 + .../kontor/admin/views/MetaDataView.java | 4 +- 11 files changed, 203 insertions(+), 25 deletions(-) diff --git a/qt/database/__init__.py b/qt/database/__init__.py index c6d21a1..c0191cc 100644 --- a/qt/database/__init__.py +++ b/qt/database/__init__.py @@ -43,7 +43,7 @@ class KontorDB: 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} + meta_data[order] = {'column': column.column_name, 'label': column.column_label, 'order': column.column_order, 'ref_column': column.ref_column} order += 1 return meta_data @@ -70,7 +70,7 @@ class KontorDB: # print(f"KontorDB.get_data: {row}") data.append(list(row)) cursor.close() - print(f"KontorDB.getData: return {len(data)}") + # print(f"KontorDB.getData: return {len(data)}") if table_name == 'comic' and len(where_clause) == 0: data.clear() comics = self.session.query(Comic).all() @@ -79,8 +79,13 @@ class KontorDB: row = [] for order in columns.keys(): column_name = columns[order]['column'] - if column_name == 'publisher_id': - row.append(item.publisher.name) + if str(column_name).endswith("_id"): + ref_table = column_name[:-3] + # print(f"{ref_table=}") + ref = getattr(item, ref_table) + value = getattr(ref, "name") + # print(f"{value=}") + row.append(value) else: row.append(getattr(item, column_name)) # print(repr(row)) diff --git a/qt/database/comic.py b/qt/database/comic.py index c17244e..dc4204f 100644 --- a/qt/database/comic.py +++ b/qt/database/comic.py @@ -11,7 +11,7 @@ class Publisher(Base): created_date = Column(DateTime) last_modified_date = Column(DateTime) version = Column(Integer) - name = Column(String(length=255)) + name = Column(String(length=255), unique=True) comics = relationship("Comic") def __repr__(self): @@ -27,14 +27,104 @@ class Comic(Base): created_date = Column(DateTime) last_modified_date = Column(DateTime) version = Column(Integer) - title = Column(String(length=255)) - publisher_id = Column(String, ForeignKey('publisher.id')) + 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") + worktype_id = Column(String, ForeignKey("worktype.id"), nullable=False) + worktype = relationship("Worktype", back_populates="comic_works") diff --git a/qt/database/metadata.py b/qt/database/metadata.py index e1b9084..46a6274 100644 --- a/qt/database/metadata.py +++ b/qt/database/metadata.py @@ -37,6 +37,7 @@ class MetaDataColumn(Base): 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: diff --git a/qt/database/tysc.py b/qt/database/tysc.py index e78ff17..9c94177 100644 --- a/qt/database/tysc.py +++ b/qt/database/tysc.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, Column, DateTime, Integer, String, ForeignKey +from sqlalchemy import Boolean, Column, DateTime, Integer, String, ForeignKey, UniqueConstraint from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship @@ -7,11 +7,14 @@ from database.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)) + name = Column(String(255), nullable=False, index=True, unique=True) teams = relationship("Team") positions = relationship("FieldPosition") @@ -22,47 +25,107 @@ class Team(Base): created_date = Column(DateTime) last_modified_date = Column(DateTime) version = Column(Integer) - name = Column(String(255)) - short_name = Column(String(255)) - sport_id = Column(String, ForeignKey("sport.id")) + 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") 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)) - short_name = Column(String(255)) - sport_id = Column(String, ForeignKey("sport.id")) + 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)) - last_name = Column(String(255)) + 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")) + team_id = Column(String, ForeignKey("team.id"), nullable=False, index=True) team = relationship("Team", back_populates="roosters") - player_id = Column(String, ForeignKey("player.id")) + 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")) + position_id = Column(String, ForeignKey("field_position.id"), nullable=False, index=True) position = relationship("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("cards") + rooster_id = Column(String, ForeignKey("rooster.id"), nullable=False) + rooster = relationship("cards") + vendor_id = Column(String, ForeignKey("vendor.id"), nullable=False) + vendor = relationship("Vendor", back_populates="cards") diff --git a/qt/gui/main_window.py b/qt/gui/main_window.py index a652906..42b4383 100644 --- a/qt/gui/main_window.py +++ b/qt/gui/main_window.py @@ -68,9 +68,13 @@ class MainWindow(QMainWindow): 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") diff --git a/qt/gui/model_config.py b/qt/gui/model_config.py index 01d3805..c2644e9 100644 --- a/qt/gui/model_config.py +++ b/qt/gui/model_config.py @@ -43,7 +43,7 @@ class KontorModelConfig: def get_data(self) -> list: data = self.kontor_db.get_data(self._table, self.header, self.get_filter()) - print(f"KontorModelConfig.get_data: {len(data)}") + # print(f"KontorModelConfig.get_data: {len(data)}") # comics = self.kontor_db.session.query(Comic).all() # print(f'{len(comics)} Comics loaded') return data diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java index 4ac89e0..b54f74e 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/SetupModuleAdmin.java @@ -179,7 +179,7 @@ public class SetupModuleAdmin implements ApplicationListener column.getColumnName().equals(columnName))) { log.debug("Column {} with name {} of table {} found, check Values", columnOrder, columnName, table.getTableName()); MetaDataColumn column = table.getTableColumns().get(columnOrder.intValue()-1); @@ -70,6 +74,10 @@ public class MetaDataService { log.debug("filterLabel has to be change to {}}", filterLabel); column.setFilterLabel(filterLabel); } + if (refColumn != null && !refColumn.equals(column.getRefColumn())) { + log.debug("refColumn has to be change to {}}", filterLabel); + column.setRefColumn(refColumn); + } metaDataColumnRepository.save(column); } else { log.info("Column {} of table {} not found, will create it", columnName, table.getTableName()); @@ -85,6 +93,7 @@ public class MetaDataService { } } + public List findAllMetaDataColumns(String stringFilter) { if (stringFilter == null || stringFilter.isEmpty()) { log.debug("Found " + metaDataColumnRepository.count()+ " entries"); diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java index b65b099..481f237 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataForm.java @@ -29,6 +29,7 @@ public class MetaDataForm extends FormLayout { TextField columnLabel = new TextField("Column Label"); Checkbox showFilter = new Checkbox("Show Filter"); TextField filterLabel = new TextField("Filter Label"); + TextField refColumn = new TextField("Ref Column"); Button save = new com.vaadin.flow.component.button.Button("Save"); Button delete = new com.vaadin.flow.component.button.Button("Delete"); @@ -47,6 +48,7 @@ public class MetaDataForm extends FormLayout { isShown.addClickListener(click -> columnLabel.setEnabled(isShown.getValue())); add(showFilter, filterLabel); showFilter.addClickListener(click -> filterLabel.setEnabled(showFilter.getValue())); + add(refColumn); add(createButtonsLayout()); } diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java index 780126a..a5f4cca 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/views/MetaDataView.java @@ -66,6 +66,8 @@ public class MetaDataView extends VerticalLayout { setHeader("Zeige Filter").setWidth("6rem").setSortable(true); Grid.Column filterLabelColumn = grid.addColumn(MetaDataColumn::getFilterLabel) .setHeader("Filter Name").setResizable(true).setSortable(true); + Grid.Column refColumnColumn = grid.addColumn(MetaDataColumn::getRefColumn) + .setHeader("Ref Column Name").setResizable(true).setSortable(true); TextField searchField = new TextField(); @Getter MetaDataForm form; @@ -186,7 +188,7 @@ public class MetaDataView extends VerticalLayout { columnToggleContextMenu.addColumnToggleItem(columnLabelColumn.getHeaderText(), columnLabelColumn); columnToggleContextMenu.addColumnToggleItem(showFilterColumn.getHeaderText(), showFilterColumn); columnToggleContextMenu.addColumnToggleItem(filterLabelColumn.getHeaderText(), filterLabelColumn); - + columnToggleContextMenu.addColumnToggleItem(refColumnColumn.getHeaderText(), refColumnColumn); HorizontalLayout toolbar = new HorizontalLayout(searchField, addMetaDataButton, menuButton); toolbar.addClassName("toolbar"); return toolbar; From 820ae3d3747708842f57f27c3c3b14b764e0ebf7 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 13 Jan 2025 00:26:42 +0100 Subject: [PATCH 23/26] create files with abstract model class --- qt/database/media.py | 6 +++--- qt/database/tysc.py | 2 +- qt/gui/data_view.py | 12 ++++++++++++ qt/gui/data_view_model.py | 33 +++++++++++++++++++++++++++++++++ qt/gui/table_model.py | 15 ++++++++++----- 5 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 qt/gui/data_view.py create mode 100644 qt/gui/data_view_model.py 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..0e77165 100644 --- a/qt/database/tysc.py +++ b/qt/database/tysc.py @@ -1,4 +1,4 @@ -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 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..e4e1bab --- /dev/null +++ b/qt/gui/data_view_model.py @@ -0,0 +1,33 @@ +from typing import List + +from PyQt5.QtCore import QAbstractTableModel +from PySide6.QtCore import QModelIndex +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/table_model.py b/qt/gui/table_model.py index 5e80c3b..e11dacc 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 From d0eae1980a2af1846d346f5d20b2bd3ce4243530 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 13 Jan 2025 16:18:13 +0100 Subject: [PATCH 24/26] add export to json --- qt/database/__init__.py | 118 +++++++++++++++++++++++++++++++------- qt/database/base.py | 19 +++++- qt/database/comic.py | 8 +-- qt/gui/data_view_model.py | 13 ++--- qt/gui/dialogs.py | 4 +- qt/gui/main_window.py | 11 ++-- qt/gui/model_config.py | 31 +++++----- qt/gui/table_model.py | 2 +- 8 files changed, 152 insertions(+), 54 deletions(-) diff --git a/qt/database/__init__.py b/qt/database/__init__.py index c0191cc..d74e927 100644 --- a/qt/database/__init__.py +++ b/qt/database/__init__.py @@ -1,3 +1,7 @@ +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 @@ -30,35 +34,67 @@ class KontorDB: __session__ = sessionmaker(bind=engine) self.session = __session__() - 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 +139,45 @@ 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) + model = Base.model_lookup_by_table_name(table) + 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..f97d724 100644 --- a/qt/database/base.py +++ b/qt/database/base.py @@ -1,7 +1,20 @@ 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 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..1dc31b4 100644 --- a/qt/database/comic.py +++ b/qt/database/comic.py @@ -1,4 +1,4 @@ -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 @@ -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/gui/data_view_model.py b/qt/gui/data_view_model.py index e4e1bab..4ffdc8c 100644 --- a/qt/gui/data_view_model.py +++ b/qt/gui/data_view_model.py @@ -1,7 +1,6 @@ from typing import List -from PyQt5.QtCore import QAbstractTableModel -from PySide6.QtCore import QModelIndex +from PySide6.QtCore import QModelIndex, QAbstractTableModel from PySide6.QtGui import Qt from gui.data_view import DataViewMeta @@ -14,19 +13,19 @@ class DataViewModel(QAbstractTableModel): self._config = None self._data = List[DataViewMeta] - def rowCount(self, parent = QModelIndex()): + def rowCount(self, parent=QModelIndex()): return len(self._data) - def columnCount(self, parent = QModelIndex()): + def columnCount(self, parent=QModelIndex()): return 0 - def headerData(self, section, orientation, role = Qt.ItemDataRole.DisplayRole): + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): return None - def data(self, index, role = Qt.ItemDataRole.DisplayRole): + def data(self, index, role=Qt.ItemDataRole.DisplayRole): return None - def setData(self, index, value, role = Qt.ItemDataRole.EditRole): + def setData(self, index, value, role=Qt.ItemDataRole.EditRole): return False def flags(self, index): 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 e11dacc..e1ba6ae 100644 --- a/qt/gui/table_model.py +++ b/qt/gui/table_model.py @@ -57,7 +57,7 @@ class KontorTableModel(QAbstractTableModel): else: return self._main_window.cross if isinstance(value, int): - print('{}:: {}: {}'.format(index, value, type(value))) + # print('{}:: {}: {}'.format(index, value, type(value))) if value == 1: return self._main_window.tick else: From f74c07af9aec29840fb9a635cd43da710f78f02c Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 13 Jan 2025 22:54:25 +0100 Subject: [PATCH 25/26] add cli app and fix relationship typos --- python/cli/.gitignore | 105 +++++++++ python/cli/CHANGELOG.md | 5 + python/cli/Dockerfile | 10 + python/cli/LICENSE.md | 1 + python/cli/MANIFEST.in | 5 + python/cli/Makefile | 31 +++ python/cli/README.md | 69 ++++++ python/cli/config/kontor.yml.example | 46 ++++ .../__init__.py => python/cli/docs/.gitkeep | 0 python/cli/kontor/__init__.py | 0 python/cli/kontor/controllers/__init__.py | 0 python/cli/kontor/controllers/clibase.py | 60 +++++ python/cli/kontor/core/__init__.py | 0 python/cli/kontor/core/exc.py | 4 + python/cli/kontor/core/version.py | 7 + .../cli/kontor}/database/__init__.py | 6 +- {qt => python/cli/kontor}/database/base.py | 0 {qt => python/cli/kontor}/database/comic.py | 2 +- python/cli/kontor/database/media.py | 25 +++ python/cli/kontor/database/metadata.py | 49 ++++ {qt => python/cli/kontor}/database/tysc.py | 2 +- python/cli/kontor/ext/__init__.py | 0 python/cli/kontor/main.py | 111 +++++++++ python/cli/kontor/plugins/__init__.py | 0 python/cli/kontor/templates/__init__.py | 0 python/cli/kontor/templates/command1.jinja2 | 4 + python/cli/requirements-dev.txt | 8 + python/cli/requirements.txt | 6 + python/cli/setup.cfg | 0 python/cli/setup.py | 28 +++ python/cli/tests/conftest.py | 16 ++ python/cli/tests/test_kontor.py | 36 +++ python/main.py | 16 ++ {qt => python/qt}/.gitignore | 0 python/qt/database/__init__.py | 212 ++++++++++++++++++ python/qt/database/base.py | 18 ++ python/qt/database/comic.py | 130 +++++++++++ {qt => python/qt}/database/media.py | 0 {qt => python/qt}/database/metadata.py | 0 python/qt/database/tysc.py | 131 +++++++++++ python/qt/gui/__init__.py | 0 {qt => python/qt}/gui/data_view.py | 0 {qt => python/qt}/gui/data_view_model.py | 0 {qt => python/qt}/gui/dialogs.py | 0 {qt => python/qt}/gui/main_window.py | 0 {qt => python/qt}/gui/model_config.py | 0 {qt => python/qt}/gui/table_model.py | 0 {qt => python/qt}/kontor.py | 0 {qt => python/qt}/pysidedeploy.spec | 0 {qt => python/qt}/res/application-export.png | Bin {qt => python/qt}/res/application-import.png | Bin {qt => python/qt}/res/arrow-circle-double.png | Bin {qt => python/qt}/res/cross.png | Bin {qt => python/qt}/res/tick.png | Bin 54 files changed, 1138 insertions(+), 5 deletions(-) create mode 100644 python/cli/.gitignore create mode 100644 python/cli/CHANGELOG.md create mode 100644 python/cli/Dockerfile create mode 100644 python/cli/LICENSE.md create mode 100644 python/cli/MANIFEST.in create mode 100644 python/cli/Makefile create mode 100644 python/cli/README.md create mode 100644 python/cli/config/kontor.yml.example rename qt/gui/__init__.py => python/cli/docs/.gitkeep (100%) create mode 100644 python/cli/kontor/__init__.py create mode 100644 python/cli/kontor/controllers/__init__.py create mode 100644 python/cli/kontor/controllers/clibase.py create mode 100644 python/cli/kontor/core/__init__.py create mode 100644 python/cli/kontor/core/exc.py create mode 100644 python/cli/kontor/core/version.py rename {qt => python/cli/kontor}/database/__init__.py (98%) rename {qt => python/cli/kontor}/database/base.py (100%) rename {qt => python/cli/kontor}/database/comic.py (99%) create mode 100644 python/cli/kontor/database/media.py create mode 100644 python/cli/kontor/database/metadata.py rename {qt => python/cli/kontor}/database/tysc.py (99%) create mode 100644 python/cli/kontor/ext/__init__.py create mode 100644 python/cli/kontor/main.py create mode 100644 python/cli/kontor/plugins/__init__.py create mode 100644 python/cli/kontor/templates/__init__.py create mode 100644 python/cli/kontor/templates/command1.jinja2 create mode 100644 python/cli/requirements-dev.txt create mode 100644 python/cli/requirements.txt create mode 100644 python/cli/setup.cfg create mode 100644 python/cli/setup.py create mode 100644 python/cli/tests/conftest.py create mode 100644 python/cli/tests/test_kontor.py create mode 100644 python/main.py rename {qt => python/qt}/.gitignore (100%) create mode 100644 python/qt/database/__init__.py create mode 100644 python/qt/database/base.py create mode 100644 python/qt/database/comic.py rename {qt => python/qt}/database/media.py (100%) rename {qt => python/qt}/database/metadata.py (100%) create mode 100644 python/qt/database/tysc.py create mode 100644 python/qt/gui/__init__.py rename {qt => python/qt}/gui/data_view.py (100%) rename {qt => python/qt}/gui/data_view_model.py (100%) rename {qt => python/qt}/gui/dialogs.py (100%) rename {qt => python/qt}/gui/main_window.py (100%) rename {qt => python/qt}/gui/model_config.py (100%) rename {qt => python/qt}/gui/table_model.py (100%) rename {qt => python/qt}/kontor.py (100%) rename {qt => python/qt}/pysidedeploy.spec (100%) rename {qt => python/qt}/res/application-export.png (100%) rename {qt => python/qt}/res/application-import.png (100%) rename {qt => python/qt}/res/arrow-circle-double.png (100%) rename {qt => python/qt}/res/cross.png (100%) rename {qt => python/qt}/res/tick.png (100%) diff --git a/python/cli/.gitignore b/python/cli/.gitignore new file mode 100644 index 0000000..a74b246 --- /dev/null +++ b/python/cli/.gitignore @@ -0,0 +1,105 @@ +# 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 + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/python/cli/CHANGELOG.md b/python/cli/CHANGELOG.md new file mode 100644 index 0000000..6c95d97 --- /dev/null +++ b/python/cli/CHANGELOG.md @@ -0,0 +1,5 @@ +# Kontor Change History + +## 0.0.1 + +Initial release. diff --git a/python/cli/Dockerfile b/python/cli/Dockerfile new file mode 100644 index 0000000..d32cf5c --- /dev/null +++ b/python/cli/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/cli/LICENSE.md b/python/cli/LICENSE.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/python/cli/LICENSE.md @@ -0,0 +1 @@ + diff --git a/python/cli/MANIFEST.in b/python/cli/MANIFEST.in new file mode 100644 index 0000000..1160952 --- /dev/null +++ b/python/cli/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/cli/Makefile b/python/cli/Makefile new file mode 100644 index 0000000..b016c3c --- /dev/null +++ b/python/cli/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/cli/README.md b/python/cli/README.md new file mode 100644 index 0000000..6afe593 --- /dev/null +++ b/python/cli/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/cli/config/kontor.yml.example b/python/cli/config/kontor.yml.example new file mode 100644 index 0000000..ba6507c --- /dev/null +++ b/python/cli/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/qt/gui/__init__.py b/python/cli/docs/.gitkeep similarity index 100% rename from qt/gui/__init__.py rename to python/cli/docs/.gitkeep diff --git a/python/cli/kontor/__init__.py b/python/cli/kontor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/controllers/__init__.py b/python/cli/kontor/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/controllers/clibase.py b/python/cli/kontor/controllers/clibase.py new file mode 100644 index 0000000..8d69fb2 --- /dev/null +++ b/python/cli/kontor/controllers/clibase.py @@ -0,0 +1,60 @@ + +from cement import Controller, ex +from cement.utils.version import get_version_banner +from ..core.version import get_version + +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 command1 --foo bar' + + # 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.app.args.print_help() + + + @ex( + help='example sub command1', + + # sub-command level arguments. ex: 'kontor command1 --foo bar' + arguments=[ + ### add a sample foo option under subcommand namespace + ( [ '-f', '--foo' ], + { 'help' : 'notorious foo option', + 'action' : 'store', + 'dest' : 'foo' } ), + ], + ) + def command1(self): + """Example sub-command.""" + + data = { + 'foo' : 'bar', + } + + ### do something with arguments + if self.app.pargs.foo is not None: + data['foo'] = self.app.pargs.foo + + self.app.render(data, 'command1.jinja2') diff --git a/python/cli/kontor/core/__init__.py b/python/cli/kontor/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/core/exc.py b/python/cli/kontor/core/exc.py new file mode 100644 index 0000000..aaeb159 --- /dev/null +++ b/python/cli/kontor/core/exc.py @@ -0,0 +1,4 @@ + +class KontorError(Exception): + """Generic errors.""" + pass diff --git a/python/cli/kontor/core/version.py b/python/cli/kontor/core/version.py new file mode 100644 index 0000000..d130f85 --- /dev/null +++ b/python/cli/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/qt/database/__init__.py b/python/cli/kontor/database/__init__.py similarity index 98% rename from qt/database/__init__.py rename to python/cli/kontor/database/__init__.py index d74e927..95ce1d8 100644 --- a/qt/database/__init__.py +++ b/python/cli/kontor/database/__init__.py @@ -6,9 +6,9 @@ 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 +from .metadata import MetaDataTable, MetaDataColumn class KontorDB: diff --git a/qt/database/base.py b/python/cli/kontor/database/base.py similarity index 100% rename from qt/database/base.py rename to python/cli/kontor/database/base.py diff --git a/qt/database/comic.py b/python/cli/kontor/database/comic.py similarity index 99% rename from qt/database/comic.py rename to python/cli/kontor/database/comic.py index 1dc31b4..49d222f 100644 --- a/qt/database/comic.py +++ b/python/cli/kontor/database/comic.py @@ -2,7 +2,7 @@ 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): diff --git a/python/cli/kontor/database/media.py b/python/cli/kontor/database/media.py new file mode 100644 index 0000000..0129c03 --- /dev/null +++ b/python/cli/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/cli/kontor/database/metadata.py b/python/cli/kontor/database/metadata.py new file mode 100644 index 0000000..2975e10 --- /dev/null +++ b/python/cli/kontor/database/metadata.py @@ -0,0 +1,49 @@ +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/qt/database/tysc.py b/python/cli/kontor/database/tysc.py similarity index 99% rename from qt/database/tysc.py rename to python/cli/kontor/database/tysc.py index 0e77165..54cacea 100644 --- a/qt/database/tysc.py +++ b/python/cli/kontor/database/tysc.py @@ -2,7 +2,7 @@ from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, UniqueCons from sqlalchemy.dialects.mysql import BIT from sqlalchemy.orm import relationship -from database.base import Base +from .base import Base class Sport(Base): diff --git a/python/cli/kontor/ext/__init__.py b/python/cli/kontor/ext/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/main.py b/python/cli/kontor/main.py new file mode 100644 index 0000000..4c71aab --- /dev/null +++ b/python/cli/kontor/main.py @@ -0,0 +1,111 @@ + +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 + +# 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__()) + + +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), + ] + # register handlers + handlers = [ + CliBase + ] + + +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/cli/kontor/plugins/__init__.py b/python/cli/kontor/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/templates/__init__.py b/python/cli/kontor/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/kontor/templates/command1.jinja2 b/python/cli/kontor/templates/command1.jinja2 new file mode 100644 index 0000000..2435e4d --- /dev/null +++ b/python/cli/kontor/templates/command1.jinja2 @@ -0,0 +1,4 @@ + +Example Template (templates/command1.jinja2) + +Foo => {{ foo }} diff --git a/python/cli/requirements-dev.txt b/python/cli/requirements-dev.txt new file mode 100644 index 0000000..f20606e --- /dev/null +++ b/python/cli/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/cli/requirements.txt b/python/cli/requirements.txt new file mode 100644 index 0000000..bee2df1 --- /dev/null +++ b/python/cli/requirements.txt @@ -0,0 +1,6 @@ +cement==3.0.12 +cement[jinja2] +cement[yaml] +cement[colorlog] +mariadb +sqlalchemy diff --git a/python/cli/setup.cfg b/python/cli/setup.cfg new file mode 100644 index 0000000..e69de29 diff --git a/python/cli/setup.py b/python/cli/setup.py new file mode 100644 index 0000000..0b92a43 --- /dev/null +++ b/python/cli/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/cli/tests/conftest.py b/python/cli/tests/conftest.py new file mode 100644 index 0000000..5124e2e --- /dev/null +++ b/python/cli/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/cli/tests/test_kontor.py b/python/cli/tests/test_kontor.py new file mode 100644 index 0000000..3c1bd67 --- /dev/null +++ b/python/cli/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/python/main.py b/python/main.py new file mode 100644 index 0000000..76d0e8c --- /dev/null +++ b/python/main.py @@ -0,0 +1,16 @@ +# This is a sample Python script. + +# Press Umschalt+F10 to execute it or replace it with your code. +# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings. + + +def print_hi(name): + # Use a breakpoint in the code line below to debug your script. + print(f'Hi, {name}') # Press Strg+F8 to toggle the breakpoint. + + +# Press the green button in the gutter to run the script. +if __name__ == '__main__': + print_hi('PyCharm') + +# See PyCharm help at https://www.jetbrains.com/help/pycharm/ diff --git a/qt/.gitignore b/python/qt/.gitignore similarity index 100% rename from qt/.gitignore rename to python/qt/.gitignore diff --git a/python/qt/database/__init__.py b/python/qt/database/__init__.py new file mode 100644 index 0000000..47f5706 --- /dev/null +++ b/python/qt/database/__init__.py @@ -0,0 +1,212 @@ +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 .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: + + def __init__(self, db_config): + self.db_conn = mariadb.connect( + host=db_config['mariadb']['host'], + port=db_config['mariadb']['port'], + user=db_config['mariadb']['user'], + password=db_config['mariadb']['password'], + database=db_config['mariadb']['database'] + ) + connect_string = ('mariadb+mariadbconnector://{}:{}@{}:{}/{}' + .format( + db_config['mariadb']['user'], + db_config['mariadb']['password'], + db_config['mariadb']['host'], + db_config['mariadb']['port'], + db_config['mariadb']['database'])) + # engine = create_engine(connect_string, echo=True) + engine = create_engine(connect_string) + 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_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} + 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 = [] + cursor = self.db_conn.cursor() + cursor.execute(self.get_statement(table_name, columns, where_clause)) + rows = cursor.fetchall() + print(len(rows)) + for row in rows: + # print(f"KontorDB.get_data: {row}") + data.append(list(row)) + cursor.close() + # print(f"KontorDB.getData: return {len(data)}") + if table_name == 'comic' and len(where_clause) == 0: + data.clear() + comics = self.session.query(Comic).all() + for item in comics: + # print(item) + 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(item, ref_table) + value = getattr(ref, "name") + # print(f"{value=}") + row.append(value) + else: + row.append(getattr(item, column_name)) + # print(repr(row)) + data.append(row) + return data + + def get_statement(self, table: str, header: dict, where_clause): + columns = "" + for index, column in header.items(): + if index > 0: + columns += ", " + columns += column['column'] + if len(columns) == 0: + columns = "*" + 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/python/qt/database/base.py b/python/qt/database/base.py new file mode 100644 index 0000000..97d2990 --- /dev/null +++ b/python/qt/database/base.py @@ -0,0 +1,18 @@ +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/python/qt/database/comic.py b/python/qt/database/comic.py new file mode 100644 index 0000000..49d222f --- /dev/null +++ b/python/qt/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/qt/database/media.py b/python/qt/database/media.py similarity index 100% rename from qt/database/media.py rename to python/qt/database/media.py diff --git a/qt/database/metadata.py b/python/qt/database/metadata.py similarity index 100% rename from qt/database/metadata.py rename to python/qt/database/metadata.py diff --git a/python/qt/database/tysc.py b/python/qt/database/tysc.py new file mode 100644 index 0000000..733d7ed --- /dev/null +++ b/python/qt/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/qt/gui/__init__.py b/python/qt/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qt/gui/data_view.py b/python/qt/gui/data_view.py similarity index 100% rename from qt/gui/data_view.py rename to python/qt/gui/data_view.py diff --git a/qt/gui/data_view_model.py b/python/qt/gui/data_view_model.py similarity index 100% rename from qt/gui/data_view_model.py rename to python/qt/gui/data_view_model.py diff --git a/qt/gui/dialogs.py b/python/qt/gui/dialogs.py similarity index 100% rename from qt/gui/dialogs.py rename to python/qt/gui/dialogs.py diff --git a/qt/gui/main_window.py b/python/qt/gui/main_window.py similarity index 100% rename from qt/gui/main_window.py rename to python/qt/gui/main_window.py diff --git a/qt/gui/model_config.py b/python/qt/gui/model_config.py similarity index 100% rename from qt/gui/model_config.py rename to python/qt/gui/model_config.py diff --git a/qt/gui/table_model.py b/python/qt/gui/table_model.py similarity index 100% rename from qt/gui/table_model.py rename to python/qt/gui/table_model.py diff --git a/qt/kontor.py b/python/qt/kontor.py similarity index 100% rename from qt/kontor.py rename to python/qt/kontor.py diff --git a/qt/pysidedeploy.spec b/python/qt/pysidedeploy.spec similarity index 100% rename from qt/pysidedeploy.spec rename to python/qt/pysidedeploy.spec diff --git a/qt/res/application-export.png b/python/qt/res/application-export.png similarity index 100% rename from qt/res/application-export.png rename to python/qt/res/application-export.png diff --git a/qt/res/application-import.png b/python/qt/res/application-import.png similarity index 100% rename from qt/res/application-import.png rename to python/qt/res/application-import.png diff --git a/qt/res/arrow-circle-double.png b/python/qt/res/arrow-circle-double.png similarity index 100% rename from qt/res/arrow-circle-double.png rename to python/qt/res/arrow-circle-double.png diff --git a/qt/res/cross.png b/python/qt/res/cross.png similarity index 100% rename from qt/res/cross.png rename to python/qt/res/cross.png diff --git a/qt/res/tick.png b/python/qt/res/tick.png similarity index 100% rename from qt/res/tick.png rename to python/qt/res/tick.png From 276302570f38e525a6a813232dc246cc400e95ba Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Tue, 14 Jan 2025 13:10:24 +0100 Subject: [PATCH 26/26] merged command line and gui app in one command --- python/{cli => }/.gitignore | 3 + python/{cli => }/CHANGELOG.md | 0 python/{cli => }/Dockerfile | 0 python/{cli => }/LICENSE.md | 0 python/{cli => }/MANIFEST.in | 0 python/{cli => }/Makefile | 0 python/{cli => }/README.md | 0 python/cli/kontor/controllers/clibase.py | 60 -------- python/cli/kontor/database/base.py | 20 --- python/{cli => }/config/kontor.yml.example | 0 python/{cli => }/docs/.gitkeep | 0 python/{cli => }/kontor/__init__.py | 0 .../{cli => }/kontor/controllers/__init__.py | 0 python/kontor/controllers/clibase.py | 42 ++++++ python/kontor/controllers/database.py | 32 ++++ python/{cli => }/kontor/core/__init__.py | 0 python/{cli => }/kontor/core/exc.py | 0 python/{cli => }/kontor/core/version.py | 0 python/{cli => }/kontor/database/__init__.py | 123 ++++++---------- python/kontor/database/base.py | 5 + python/{cli => }/kontor/database/comic.py | 0 python/{cli => }/kontor/database/media.py | 0 python/{cli => }/kontor/database/metadata.py | 1 + python/{qt => kontor}/database/tysc.py | 0 python/{cli => }/kontor/ext/__init__.py | 0 .../kontor/plugins => kontor/gui}/__init__.py | 0 python/{qt => kontor}/gui/data_view.py | 0 python/{qt => kontor}/gui/data_view_model.py | 0 python/{qt => kontor}/gui/dialogs.py | 0 python/kontor/gui/main_window.py | 139 ++++++++++++++++++ python/kontor/gui/model_config.py | 50 +++++++ python/kontor/gui/table_model.py | 108 ++++++++++++++ python/{cli => }/kontor/main.py | 14 +- .../templates => kontor/plugins}/__init__.py | 0 .../{qt => kontor}/res/application-export.png | Bin .../{qt => kontor}/res/application-import.png | Bin .../res/arrow-circle-double.png | Bin python/{qt => kontor}/res/cross.png | Bin python/{qt => kontor}/res/tick.png | Bin .../{qt/gui => kontor/templates}/__init__.py | 0 .../kontor/templates/command1.jinja2 | 0 python/main.py | 16 -- python/{cli => }/requirements-dev.txt | 0 python/{cli => }/requirements.txt | 1 + python/{cli => }/setup.cfg | 0 python/{cli => }/setup.py | 0 python/{cli => }/tests/conftest.py | 0 python/{cli => }/tests/test_kontor.py | 0 {python/qt => qt}/.gitignore | 0 {python/qt => qt}/database/__init__.py | 0 {python/qt => qt}/database/base.py | 0 {python/qt => qt}/database/comic.py | 0 {python/qt => qt}/database/media.py | 0 {python/qt => qt}/database/metadata.py | 0 {python/cli/kontor => qt}/database/tysc.py | 8 +- qt/gui/__init__.py | 0 qt/gui/data_view.py | 12 ++ qt/gui/data_view_model.py | 32 ++++ qt/gui/dialogs.py | 106 +++++++++++++ {python/qt => qt}/gui/main_window.py | 0 {python/qt => qt}/gui/model_config.py | 0 {python/qt => qt}/gui/table_model.py | 0 {python/qt => qt}/kontor.py | 0 {python/qt => qt}/pysidedeploy.spec | 0 qt/res/application-export.png | Bin 0 -> 513 bytes qt/res/application-import.png | Bin 0 -> 524 bytes qt/res/arrow-circle-double.png | Bin 0 -> 836 bytes qt/res/cross.png | Bin 0 -> 544 bytes qt/res/tick.png | Bin 0 -> 634 bytes 69 files changed, 589 insertions(+), 183 deletions(-) rename python/{cli => }/.gitignore (98%) rename python/{cli => }/CHANGELOG.md (100%) rename python/{cli => }/Dockerfile (100%) rename python/{cli => }/LICENSE.md (100%) rename python/{cli => }/MANIFEST.in (100%) rename python/{cli => }/Makefile (100%) rename python/{cli => }/README.md (100%) delete mode 100644 python/cli/kontor/controllers/clibase.py delete mode 100644 python/cli/kontor/database/base.py rename python/{cli => }/config/kontor.yml.example (100%) rename python/{cli => }/docs/.gitkeep (100%) rename python/{cli => }/kontor/__init__.py (100%) rename python/{cli => }/kontor/controllers/__init__.py (100%) create mode 100644 python/kontor/controllers/clibase.py create mode 100644 python/kontor/controllers/database.py rename python/{cli => }/kontor/core/__init__.py (100%) rename python/{cli => }/kontor/core/exc.py (100%) rename python/{cli => }/kontor/core/version.py (100%) rename python/{cli => }/kontor/database/__init__.py (55%) create mode 100644 python/kontor/database/base.py rename python/{cli => }/kontor/database/comic.py (100%) rename python/{cli => }/kontor/database/media.py (100%) rename python/{cli => }/kontor/database/metadata.py (99%) rename python/{qt => kontor}/database/tysc.py (100%) rename python/{cli => }/kontor/ext/__init__.py (100%) rename python/{cli/kontor/plugins => kontor/gui}/__init__.py (100%) rename python/{qt => kontor}/gui/data_view.py (100%) rename python/{qt => kontor}/gui/data_view_model.py (100%) rename python/{qt => kontor}/gui/dialogs.py (100%) create mode 100644 python/kontor/gui/main_window.py create mode 100644 python/kontor/gui/model_config.py create mode 100644 python/kontor/gui/table_model.py rename python/{cli => }/kontor/main.py (91%) rename python/{cli/kontor/templates => kontor/plugins}/__init__.py (100%) rename python/{qt => kontor}/res/application-export.png (100%) rename python/{qt => kontor}/res/application-import.png (100%) rename python/{qt => kontor}/res/arrow-circle-double.png (100%) rename python/{qt => kontor}/res/cross.png (100%) rename python/{qt => kontor}/res/tick.png (100%) rename python/{qt/gui => kontor/templates}/__init__.py (100%) rename python/{cli => }/kontor/templates/command1.jinja2 (100%) delete mode 100644 python/main.py rename python/{cli => }/requirements-dev.txt (100%) rename python/{cli => }/requirements.txt (90%) rename python/{cli => }/setup.cfg (100%) rename python/{cli => }/setup.py (100%) rename python/{cli => }/tests/conftest.py (100%) rename python/{cli => }/tests/test_kontor.py (100%) rename {python/qt => qt}/.gitignore (100%) rename {python/qt => qt}/database/__init__.py (100%) rename {python/qt => qt}/database/base.py (100%) rename {python/qt => qt}/database/comic.py (100%) rename {python/qt => qt}/database/media.py (100%) rename {python/qt => qt}/database/metadata.py (100%) rename {python/cli/kontor => qt}/database/tysc.py (94%) create mode 100644 qt/gui/__init__.py create mode 100644 qt/gui/data_view.py create mode 100644 qt/gui/data_view_model.py create mode 100644 qt/gui/dialogs.py rename {python/qt => qt}/gui/main_window.py (100%) rename {python/qt => qt}/gui/model_config.py (100%) rename {python/qt => qt}/gui/table_model.py (100%) rename {python/qt => qt}/kontor.py (100%) rename {python/qt => qt}/pysidedeploy.spec (100%) mode change 100755 => 100644 create mode 100644 qt/res/application-export.png create mode 100644 qt/res/application-import.png create mode 100644 qt/res/arrow-circle-double.png create mode 100644 qt/res/cross.png create mode 100644 qt/res/tick.png diff --git a/python/cli/.gitignore b/python/.gitignore similarity index 98% rename from python/cli/.gitignore rename to python/.gitignore index a74b246..0f47561 100644 --- a/python/cli/.gitignore +++ b/python/.gitignore @@ -95,6 +95,9 @@ venv.bak/ .spyderproject .spyproject +# PyCharm +.idea/ + # Rope project settings .ropeproject diff --git a/python/cli/CHANGELOG.md b/python/CHANGELOG.md similarity index 100% rename from python/cli/CHANGELOG.md rename to python/CHANGELOG.md diff --git a/python/cli/Dockerfile b/python/Dockerfile similarity index 100% rename from python/cli/Dockerfile rename to python/Dockerfile diff --git a/python/cli/LICENSE.md b/python/LICENSE.md similarity index 100% rename from python/cli/LICENSE.md rename to python/LICENSE.md diff --git a/python/cli/MANIFEST.in b/python/MANIFEST.in similarity index 100% rename from python/cli/MANIFEST.in rename to python/MANIFEST.in diff --git a/python/cli/Makefile b/python/Makefile similarity index 100% rename from python/cli/Makefile rename to python/Makefile diff --git a/python/cli/README.md b/python/README.md similarity index 100% rename from python/cli/README.md rename to python/README.md diff --git a/python/cli/kontor/controllers/clibase.py b/python/cli/kontor/controllers/clibase.py deleted file mode 100644 index 8d69fb2..0000000 --- a/python/cli/kontor/controllers/clibase.py +++ /dev/null @@ -1,60 +0,0 @@ - -from cement import Controller, ex -from cement.utils.version import get_version_banner -from ..core.version import get_version - -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 command1 --foo bar' - - # 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.app.args.print_help() - - - @ex( - help='example sub command1', - - # sub-command level arguments. ex: 'kontor command1 --foo bar' - arguments=[ - ### add a sample foo option under subcommand namespace - ( [ '-f', '--foo' ], - { 'help' : 'notorious foo option', - 'action' : 'store', - 'dest' : 'foo' } ), - ], - ) - def command1(self): - """Example sub-command.""" - - data = { - 'foo' : 'bar', - } - - ### do something with arguments - if self.app.pargs.foo is not None: - data['foo'] = self.app.pargs.foo - - self.app.render(data, 'command1.jinja2') diff --git a/python/cli/kontor/database/base.py b/python/cli/kontor/database/base.py deleted file mode 100644 index f97d724..0000000 --- a/python/cli/kontor/database/base.py +++ /dev/null @@ -1,20 +0,0 @@ -from sqlalchemy import Column, String, DateTime, Integer -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/python/cli/config/kontor.yml.example b/python/config/kontor.yml.example similarity index 100% rename from python/cli/config/kontor.yml.example rename to python/config/kontor.yml.example diff --git a/python/cli/docs/.gitkeep b/python/docs/.gitkeep similarity index 100% rename from python/cli/docs/.gitkeep rename to python/docs/.gitkeep diff --git a/python/cli/kontor/__init__.py b/python/kontor/__init__.py similarity index 100% rename from python/cli/kontor/__init__.py rename to python/kontor/__init__.py diff --git a/python/cli/kontor/controllers/__init__.py b/python/kontor/controllers/__init__.py similarity index 100% rename from python/cli/kontor/controllers/__init__.py rename to python/kontor/controllers/__init__.py 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/cli/kontor/core/__init__.py b/python/kontor/core/__init__.py similarity index 100% rename from python/cli/kontor/core/__init__.py rename to python/kontor/core/__init__.py diff --git a/python/cli/kontor/core/exc.py b/python/kontor/core/exc.py similarity index 100% rename from python/cli/kontor/core/exc.py rename to python/kontor/core/exc.py diff --git a/python/cli/kontor/core/version.py b/python/kontor/core/version.py similarity index 100% rename from python/cli/kontor/core/version.py rename to python/kontor/core/version.py diff --git a/python/cli/kontor/database/__init__.py b/python/kontor/database/__init__.py similarity index 55% rename from python/cli/kontor/database/__init__.py rename to python/kontor/database/__init__.py index 95ce1d8..c7e6cb5 100644 --- a/python/cli/kontor/database/__init__.py +++ b/python/kontor/database/__init__.py @@ -2,37 +2,40 @@ 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 .base import Base -from .comic import Comic +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_config): - self.db_conn = mariadb.connect( - host=db_config['mariadb']['host'], - port=db_config['mariadb']['port'], - user=db_config['mariadb']['user'], - password=db_config['mariadb']['password'], - database=db_config['mariadb']['database'] - ) - connect_string = ('mariadb+mariadbconnector://{}:{}@{}:{}/{}' - .format( - db_config['mariadb']['user'], - db_config['mariadb']['password'], - db_config['mariadb']['host'], - db_config['mariadb']['port'], - db_config['mariadb']['database'])) - # engine = create_engine(connect_string, echo=True) - engine = create_engine(connect_string) - Base.metadata.create_all(bind=engine) - __session__ = sessionmaker(bind=engine) - self.session = __session__() + 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() @@ -69,7 +72,7 @@ class KontorDB: 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}") + self.log.info(f"retrieved {len(_filter_map)} filters: {_filter_map}") return _filter_map def data(self, table, columns: dict, filters) -> list: @@ -96,67 +99,27 @@ class KontorDB: data.append(row) return data - def get_data(self, table_name: str, columns: dict, where_clause: str) -> list: - data = [] - cursor = self.db_conn.cursor() - cursor.execute(self.get_statement(table_name, columns, where_clause)) - rows = cursor.fetchall() - print(len(rows)) - for row in rows: - # print(f"KontorDB.get_data: {row}") - data.append(list(row)) - cursor.close() - # print(f"KontorDB.getData: return {len(data)}") - if table_name == 'comic' and len(where_clause) == 0: - data.clear() - comics = self.session.query(Comic).all() - for item in comics: - # print(item) - 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(item, ref_table) - value = getattr(ref, "name") - # print(f"{value=}") - row.append(value) - else: - row.append(getattr(item, column_name)) - # print(repr(row)) - data.append(row) - return data - - def get_statement(self, table: str, header: dict, where_clause): - columns = "" - for index, column in header.items(): - if index > 0: - columns += ", " - columns += column['column'] - if len(columns) == 0: - columns = "*" - 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}") + 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) - model = Base.model_lookup_by_table_name(table) + 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") + self.log.debug(f"found {len(rows)} entries") + self.log.debug(f"found {len(columns)} columns") for row in rows: - print(row) + # print(row) entry = {} for order in columns: - print(columns[order]) + # print(columns[order]) column_name = columns[order]['column'] - print(f"get value {column_name} from {row} of table {table}") + # print(f"get value {column_name} from {row} of table {table}") try: value = getattr(row, column_name) if isinstance(value, datetime): @@ -164,7 +127,7 @@ class KontorDB: else: entry[column_name] = value except AttributeError as error: - print("could not get value") + self.log.debug("could not get value") entries.append(entry) db[table] = entries export_file = Path(export_file_name) @@ -178,6 +141,6 @@ class KontorDB: case "SQLite": export_file = Path(export_file_name) case _: - print("unknown export type") + self.log.debug("unknown export type") if export_file.exists(): - print(f"{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/cli/kontor/database/comic.py b/python/kontor/database/comic.py similarity index 100% rename from python/cli/kontor/database/comic.py rename to python/kontor/database/comic.py diff --git a/python/cli/kontor/database/media.py b/python/kontor/database/media.py similarity index 100% rename from python/cli/kontor/database/media.py rename to python/kontor/database/media.py diff --git a/python/cli/kontor/database/metadata.py b/python/kontor/database/metadata.py similarity index 99% rename from python/cli/kontor/database/metadata.py rename to python/kontor/database/metadata.py index 2975e10..d7dd4c0 100644 --- a/python/cli/kontor/database/metadata.py +++ b/python/kontor/database/metadata.py @@ -20,6 +20,7 @@ class MetaDataTable(Base): def __str__(self): return f'{self.table_name}({self.id})' + class MetaDataColumn(Base): __tablename__ = 'meta_data_column' id = Column(String, primary_key=True) diff --git a/python/qt/database/tysc.py b/python/kontor/database/tysc.py similarity index 100% rename from python/qt/database/tysc.py rename to python/kontor/database/tysc.py diff --git a/python/cli/kontor/ext/__init__.py b/python/kontor/ext/__init__.py similarity index 100% rename from python/cli/kontor/ext/__init__.py rename to python/kontor/ext/__init__.py diff --git a/python/cli/kontor/plugins/__init__.py b/python/kontor/gui/__init__.py similarity index 100% rename from python/cli/kontor/plugins/__init__.py rename to python/kontor/gui/__init__.py diff --git a/python/qt/gui/data_view.py b/python/kontor/gui/data_view.py similarity index 100% rename from python/qt/gui/data_view.py rename to python/kontor/gui/data_view.py diff --git a/python/qt/gui/data_view_model.py b/python/kontor/gui/data_view_model.py similarity index 100% rename from python/qt/gui/data_view_model.py rename to python/kontor/gui/data_view_model.py diff --git a/python/qt/gui/dialogs.py b/python/kontor/gui/dialogs.py similarity index 100% rename from python/qt/gui/dialogs.py rename to python/kontor/gui/dialogs.py 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/cli/kontor/main.py b/python/kontor/main.py similarity index 91% rename from python/cli/kontor/main.py rename to python/kontor/main.py index 4c71aab..aa71e06 100644 --- a/python/cli/kontor/main.py +++ b/python/kontor/main.py @@ -1,4 +1,3 @@ - from cement import App, TestApp, init_defaults from cement.core.exc import CaughtSignal from sqlalchemy import create_engine @@ -7,6 +6,7 @@ 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') @@ -17,6 +17,7 @@ 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( @@ -33,6 +34,11 @@ def extend_sqlalchemy(app): app.extend('session', __session__()) +def close_session(app): + app.log.info('close session') + app.session.close() + + class Kontor(App): """Kontor primary application.""" @@ -66,14 +72,16 @@ class Kontor(App): hooks = [ ('post_setup', extend_sqlalchemy), + ('pre_close', close_session), ] # register handlers handlers = [ - CliBase + CliBase, + Database, ] -class KontorTest(TestApp,Kontor): +class KontorTest(TestApp, Kontor): """A sub-class of Kontor that is better suited for testing.""" class Meta: diff --git a/python/cli/kontor/templates/__init__.py b/python/kontor/plugins/__init__.py similarity index 100% rename from python/cli/kontor/templates/__init__.py rename to python/kontor/plugins/__init__.py diff --git a/python/qt/res/application-export.png b/python/kontor/res/application-export.png similarity index 100% rename from python/qt/res/application-export.png rename to python/kontor/res/application-export.png diff --git a/python/qt/res/application-import.png b/python/kontor/res/application-import.png similarity index 100% rename from python/qt/res/application-import.png rename to python/kontor/res/application-import.png diff --git a/python/qt/res/arrow-circle-double.png b/python/kontor/res/arrow-circle-double.png similarity index 100% rename from python/qt/res/arrow-circle-double.png rename to python/kontor/res/arrow-circle-double.png diff --git a/python/qt/res/cross.png b/python/kontor/res/cross.png similarity index 100% rename from python/qt/res/cross.png rename to python/kontor/res/cross.png diff --git a/python/qt/res/tick.png b/python/kontor/res/tick.png similarity index 100% rename from python/qt/res/tick.png rename to python/kontor/res/tick.png diff --git a/python/qt/gui/__init__.py b/python/kontor/templates/__init__.py similarity index 100% rename from python/qt/gui/__init__.py rename to python/kontor/templates/__init__.py diff --git a/python/cli/kontor/templates/command1.jinja2 b/python/kontor/templates/command1.jinja2 similarity index 100% rename from python/cli/kontor/templates/command1.jinja2 rename to python/kontor/templates/command1.jinja2 diff --git a/python/main.py b/python/main.py deleted file mode 100644 index 76d0e8c..0000000 --- a/python/main.py +++ /dev/null @@ -1,16 +0,0 @@ -# This is a sample Python script. - -# Press Umschalt+F10 to execute it or replace it with your code. -# Press Double Shift to search everywhere for classes, files, tool windows, actions, and settings. - - -def print_hi(name): - # Use a breakpoint in the code line below to debug your script. - print(f'Hi, {name}') # Press Strg+F8 to toggle the breakpoint. - - -# Press the green button in the gutter to run the script. -if __name__ == '__main__': - print_hi('PyCharm') - -# See PyCharm help at https://www.jetbrains.com/help/pycharm/ diff --git a/python/cli/requirements-dev.txt b/python/requirements-dev.txt similarity index 100% rename from python/cli/requirements-dev.txt rename to python/requirements-dev.txt diff --git a/python/cli/requirements.txt b/python/requirements.txt similarity index 90% rename from python/cli/requirements.txt rename to python/requirements.txt index bee2df1..06a15f9 100644 --- a/python/cli/requirements.txt +++ b/python/requirements.txt @@ -4,3 +4,4 @@ cement[yaml] cement[colorlog] mariadb sqlalchemy +PySide6 diff --git a/python/cli/setup.cfg b/python/setup.cfg similarity index 100% rename from python/cli/setup.cfg rename to python/setup.cfg diff --git a/python/cli/setup.py b/python/setup.py similarity index 100% rename from python/cli/setup.py rename to python/setup.py diff --git a/python/cli/tests/conftest.py b/python/tests/conftest.py similarity index 100% rename from python/cli/tests/conftest.py rename to python/tests/conftest.py diff --git a/python/cli/tests/test_kontor.py b/python/tests/test_kontor.py similarity index 100% rename from python/cli/tests/test_kontor.py rename to python/tests/test_kontor.py diff --git a/python/qt/.gitignore b/qt/.gitignore similarity index 100% rename from python/qt/.gitignore rename to qt/.gitignore diff --git a/python/qt/database/__init__.py b/qt/database/__init__.py similarity index 100% rename from python/qt/database/__init__.py rename to qt/database/__init__.py diff --git a/python/qt/database/base.py b/qt/database/base.py similarity index 100% rename from python/qt/database/base.py rename to qt/database/base.py diff --git a/python/qt/database/comic.py b/qt/database/comic.py similarity index 100% rename from python/qt/database/comic.py rename to qt/database/comic.py diff --git a/python/qt/database/media.py b/qt/database/media.py similarity index 100% rename from python/qt/database/media.py rename to qt/database/media.py diff --git a/python/qt/database/metadata.py b/qt/database/metadata.py similarity index 100% rename from python/qt/database/metadata.py rename to qt/database/metadata.py diff --git a/python/cli/kontor/database/tysc.py b/qt/database/tysc.py similarity index 94% rename from python/cli/kontor/database/tysc.py rename to qt/database/tysc.py index 54cacea..733d7ed 100644 --- a/python/cli/kontor/database/tysc.py +++ b/qt/database/tysc.py @@ -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/__init__.py b/qt/gui/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..21dbe92 --- /dev/null +++ b/qt/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/qt/gui/main_window.py b/qt/gui/main_window.py similarity index 100% rename from python/qt/gui/main_window.py rename to qt/gui/main_window.py diff --git a/python/qt/gui/model_config.py b/qt/gui/model_config.py similarity index 100% rename from python/qt/gui/model_config.py rename to qt/gui/model_config.py diff --git a/python/qt/gui/table_model.py b/qt/gui/table_model.py similarity index 100% rename from python/qt/gui/table_model.py rename to qt/gui/table_model.py diff --git a/python/qt/kontor.py b/qt/kontor.py similarity index 100% rename from python/qt/kontor.py rename to qt/kontor.py diff --git a/python/qt/pysidedeploy.spec b/qt/pysidedeploy.spec old mode 100755 new mode 100644 similarity index 100% rename from python/qt/pysidedeploy.spec rename to qt/pysidedeploy.spec diff --git a/qt/res/application-export.png b/qt/res/application-export.png new file mode 100644 index 0000000000000000000000000000000000000000..555887a28d64bc812c4dfa98a6ff1da1927b7792 GIT binary patch literal 513 zcmV+c0{;DpP)A0|fCeqz@KJVX8nnt2D9k2UrOajFq_SwR4OUbbUJ;HgeDQF66oE777mBiWdd2F(NKOO zk$Ax17hTui{#6jPXfz7dY87I!*bgRr4TVA=afmd=7~nkYBq$b(su#^>Q?2y;%rJ~= zA;j4sgM^B|5YOb(M4cc`5q!^h_wPg5is0Dq{42l!6{r`}{QE*r00000NkvXXu0mjf Djqud07&@TR)18#v5Hqj8@|Bnpn>z;V`CueWd< z7oI05i2^A#D9Qz9v#hs`;>*D7{eB7>0ppH2SLstJO-8BwR~M`A7kF ztyTlI66tgrtyT-MSPa~yU>3AuKR54&OXJih@;oco-=1sDLQa}`@LC7Iy> O0000dz zNLw#yfl;S$OS+NxP!T0`q27a_KcR=-dJ5{Xm+WEf!J>k|BqE5#C}ImGwQ$$<(wdh& z>AIJ5zVFx9lo8J{@aWY%oIP6CAZKYHol)uRf|554m_YTnpAlGl zL9{5M4)qVt#u>|mLQVrh46q|<)MP|~t8EE-8crSv>~io{4+abW2A~lV0z>OM)mbBD zmWtqV;+?9YGlhpQQ@M9zi{*KRG_T5PQ_nvC9tMo_mT6XEIU5;J+S?d(HD_0f!1ES7 z7@Uj?4!kD>&?r8e&c1jan^9Cs!}3z0d7Izafj)-}rbYZR}EdihS6`M#Fcy= zaw{u#mV;BNWe3{Sqiq;{_M>cOP*8MhP1CLQf3t|C2tpM55ycYh7RL%}Yf+Bp#e!~C z2(WG^ZnlxC>igv!sW2GM8SslVJ(oFwkp~5*6E^0rT$Q zya}^2lB>ITrX0zGEa!lc90$HC4~#^g#%ZB2j3vzHhi;TO$n{?Wwx(^0>^QA_x~H^z zTf=2SxY0rCf|fl@rb7TXYr%pE(0=u@G literal 0 HcmV?d00001 diff --git a/qt/res/cross.png b/qt/res/cross.png new file mode 100644 index 0000000000000000000000000000000000000000..6b9fa6dd36ee8165272a13dd263f573507c78ca6 GIT binary patch literal 544 zcmV+*0^j|KP)L-ku(! z6_?D-?!0+#=VtbVZQGdTnZt~apI_HPK#=zVadHM((E`e*C+RoFb)Qo8-U{Lb7{`Tz zw4B8FZ|o?Wox+p=1wp47C;7ar*Xu~;a?<=sjPv>+otDjJ6FaGt!YnNyxQQhp)G1V! zk;r6ZtyV)c8pTbiRAnHRNXTxti%=+p$4aG2*+mMM&xrdiU^`VPk^N*+HX98^7!HT% zwA%;A{T)GxeQ|RcxyzV%! z$AbYD+)i1R64zB?q}NmTz}5|04~J#YG_goA*Eq(QJvp7pG14hUj1pZ^t>3S*xqHUO ze~r;}zTY_XkZ*}d@gm!;N90h8nBQenBUZ_ukZKNicn*hc_Pmc!Jn|2=s<~}>!Sb>Qm3!QP46b_JFw5YU70Tu$X}=T}ez@@t%j iG9vD)nDux55?}x$+|UyQVK_bj0000tYd4K$mX5uyr2F@fdffp{&DSHtl4|Bn9=ukpCx`#%PTUr-D(@b7;C zhJXJjrnw{;1KBM=6&|E`fsNrGL!Y6%zUh}QUl`(@V)PmQFtotEKmafTHP0uP}7&%m4pcKQ#X)BiAJ2y+PrtBI>9eEIt2-_c7)?*Lsf z5vX5*Af@m-wBJRV%#Fiz=BdPr0!2^a2d-yv7gSU);Z6{`~Oy?E5qSnHUln4*Y%!){F!Y1~5WXoC44g zb7l_)X~q^V6MmWR7e3wj|L|Wb!|^}Y86N$^h+kv_Kw-fP!~#If$6(B4$)LlK#&G6; zC&ShMSAb$5tA9Xg5dOsg@-z^@3;;QS9f&!gG&3|;{Da~@Q2ZB({s%XJ5&#fj0J|In U+D>(nEdT%j07*qoM6N<$g0@&8X#fBK literal 0 HcmV?d00001