From 66d61e2c1f41a7c1ecfbd3eca81beed9c3fca0b0 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 21:08:15 +0100 Subject: [PATCH 01/19] add entity token --- LICENSE | 9 +++++ .../de/thpeetz/kontor/admin/data/Token.java | 35 +++++++++++++++++++ .../de/thpeetz/kontor/admin/data/User.java | 8 ++--- 3 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 LICENSE create mode 100644 springboot/src/main/java/de/thpeetz/kontor/admin/data/Token.java diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7cfa22c --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 Kontor + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/springboot/src/main/java/de/thpeetz/kontor/admin/data/Token.java b/springboot/src/main/java/de/thpeetz/kontor/admin/data/Token.java new file mode 100644 index 0000000..8e77f90 --- /dev/null +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/Token.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.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; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import java.util.Date; + +@Slf4j +@Getter +@Setter +@ToString +@Entity +@Table(indexes = @Index(columnList = "token"), uniqueConstraints = @UniqueConstraint(columnNames = {"token"})) +public class Token extends AbstractEntity { + + private String token; + + private Date lastUsedDate; + + private boolean enabled; + + @ManyToOne + @JoinColumn(name="user_id") + @NotNull + private User user; +} 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 index a80b6bc..ec16233 100644 --- a/springboot/src/main/java/de/thpeetz/kontor/admin/data/User.java +++ b/springboot/src/main/java/de/thpeetz/kontor/admin/data/User.java @@ -38,14 +38,14 @@ public class User extends AbstractEntity { private boolean enabled; - private boolean tokenExpired; - - private String token; - @OneToMany(fetch = FetchType.EAGER, mappedBy = "user") @Nullable private List matrix = new LinkedList<>(); + @OneToMany(fetch = FetchType.EAGER, mappedBy = "user") + @Nullable + private List tokens = new LinkedList<>(); + public String getFullName() { StringBuilder fullNamBuilder = new StringBuilder(); if (firstName != null) { From 3a0a0055a630d20e87b4ae97c14eeb0a95b244f8 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Thu, 5 Dec 2024 20:40:13 +0100 Subject: [PATCH 02/19] 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 afb3ac88c8fd6f99e019ef9619d7d3c7eb6a2cb4 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 14:10:15 +0100 Subject: [PATCH 03/19] first implementation to show Comics and MediaFiles --- 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/cross.png | Bin 0 -> 544 bytes gui/kontor.py | 2 + gui/media_file_model.py | 91 ++++++++++++++++++++++++++++++++++++ gui/tick.png | Bin 0 -> 634 bytes 7 files changed, 93 insertions(+) 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/cross.png create mode 100644 gui/media_file_model.py create mode 100644 gui/tick.png 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/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 245ee2378e4188f25a71fe2734e4246e6e903755 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 14:13:15 +0100 Subject: [PATCH 04/19] 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 c8ea7c418867fc1f3ac7ac37d2098c8e5102b1a4 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 21:47:27 +0100 Subject: [PATCH 05/19] refresh table when updated --- gui/application-export.png | Bin 513 -> 0 bytes gui/application-import.png | Bin 524 -> 0 bytes gui/arrow-circle-double.png | Bin 836 -> 0 bytes gui/cross.png | Bin 544 -> 0 bytes gui/kontor.py | 3 +-- gui/media_file_model.py | 36 ++++++++++++++++++++++++++++++------ gui/tick.png | Bin 634 -> 0 bytes 7 files changed, 31 insertions(+), 8 deletions(-) delete mode 100644 gui/application-export.png delete mode 100644 gui/application-import.png delete mode 100644 gui/arrow-circle-double.png delete mode 100644 gui/cross.png delete mode 100644 gui/tick.png diff --git a/gui/application-export.png b/gui/application-export.png deleted file mode 100644 index 555887a28d64bc812c4dfa98a6ff1da1927b7792..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/gui/cross.png b/gui/cross.png deleted file mode 100644 index 6b9fa6dd36ee8165272a13dd263f573507c78ca6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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_bj0000 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/tick.png b/gui/tick.png deleted file mode 100644 index 2414885b8576481fb6350ed9ce01ab033169fd75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 634 zcmV-=0)_pFP)tYd4K$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 From ce1514f20ad0d1f9ff8340f48f97692061cb962e Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 5 Jan 2025 23:44:29 +0100 Subject: [PATCH 06/19] 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 d6410e25846a5ae8fab844280b8f7138c558199b Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 6 Jan 2025 17:07:20 +0100 Subject: [PATCH 07/19] implement generic table model implement generic table model which reads table info from db and constructs table view --- gui/kontor.py | 2 +- gui/media_file_model.py | 115 ---------------------------------------- 2 files changed, 1 insertion(+), 116 deletions(-) delete mode 100644 gui/media_file_model.py diff --git a/gui/kontor.py b/gui/kontor.py index fcdeb82..59e770c 100644 --- a/gui/kontor.py +++ b/gui/kontor.py @@ -6,7 +6,7 @@ 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 diff --git a/gui/media_file_model.py b/gui/media_file_model.py deleted file mode 100644 index e5af2a4..0000000 --- a/gui/media_file_model.py +++ /dev/null @@ -1,115 +0,0 @@ -from datetime import datetime - -import mariadb -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt -from PySide6.QtGui import QColor - - -class MediaFileTableModel(QAbstractTableModel): - - def __init__(self, db_config, main_window): - 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() - - 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}") - rows = cursor.fetchall() - 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 - 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): - 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" - 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()] - 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 self.main_window.tick - else: - 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 - 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) - if self._data is None: - return 5 - 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 From 0d2f27f7718a1a31a0dd49cb336526fa3ab6f2cc Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 22:31:20 +0100 Subject: [PATCH 08/19] 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 28746adfbbcbcfb6896da45877f9a77bf5ed1fd9 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Wed, 8 Jan 2025 22:34:21 +0100 Subject: [PATCH 09/19] 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 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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/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 11/19] 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 f1d36acff78d92f24c3ebcbd11ab9e3762b21685 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Fri, 10 Jan 2025 17:39:54 +0100 Subject: [PATCH 12/19] 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 59e770c..ea53ba5 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 from media_file_model import MediaFileTableModel 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 d2b1cb999adcb7366588c54141c735545703b3d7 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sat, 11 Jan 2025 01:54:05 +0100 Subject: [PATCH 13/19] 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 | 21 +---- {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, 136 insertions(+), 77 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 (90%) 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 90% rename from gui/kontor.py rename to qt/gui/main_window.py index ea53ba5..ea288df 100644 --- a/gui/kontor.py +++ b/qt/gui/main_window.py @@ -1,14 +1,6 @@ -""" -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 @@ -138,14 +130,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 5affa16505b17517ab636fcc565417a655f64e29 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sat, 11 Jan 2025 19:00:58 +0100 Subject: [PATCH 14/19] 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 954dab289ad74ceb3360fa9e671565a9e5f38575 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Sun, 12 Jan 2025 02:41:40 +0100 Subject: [PATCH 15/19] 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 ea288df..020326f 100644 --- a/qt/gui/main_window.py +++ b/qt/gui/main_window.py @@ -69,9 +69,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 3f0a37ff19e29f90a3771ef77ecbc868c79ca58b Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 13 Jan 2025 00:26:42 +0100 Subject: [PATCH 16/19] 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 89b7b87b8cceac37a4557a464dff2ff9919f1459 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 13 Jan 2025 16:18:13 +0100 Subject: [PATCH 17/19] 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 | 9 +-- qt/gui/model_config.py | 31 +++++----- qt/gui/table_model.py | 2 +- 8 files changed, 150 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 020326f..66f2302 100644 --- a/qt/gui/main_window.py +++ b/qt/gui/main_window.py @@ -34,8 +34,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) @@ -112,6 +112,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) @@ -121,9 +122,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 54bc17ee7d34a83867e96d941fd6976e29e96f17 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Mon, 13 Jan 2025 22:54:25 +0100 Subject: [PATCH 18/19] 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 453ad2af7bdbedf447eb5dd9b8a6aab85d46e4b2 Mon Sep 17 00:00:00 2001 From: Thomas Peetz Date: Tue, 14 Jan 2025 13:10:24 +0100 Subject: [PATCH 19/19] 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