Compare commits

..

1 Commits

Author SHA1 Message Date
7a72287c00 implement searchtopics
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2021-07-05 20:39:49 +02:00
22 changed files with 110 additions and 673 deletions

View File

@ -26,6 +26,9 @@ steps:
from_secret: "docker_username"
password:
from_secret: "docker_password"
when:
event:
- tag
image_pull_secrets:
- dockerconfig

View File

@ -16,8 +16,6 @@ sqlalchemy = "~=1.4.18"
waitress = "~=2.0.0"
pyyaml = "~=5.4.1"
flask-cors = "~=3.0.10"
ldap3 = "~=2.9"
pymysql = "~=1.0.2"
[dev-packages]
flake8 = "~=3.9.2"

162
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "332b04c923ecb74bc066ee5348a664274ce87736981cc8e56e60070ff26e7d0e"
"sha256": "128a3b90d2e3d5876c5942fb256dba2d5e3d0b3a545cae6d51aeda6925a0f593"
},
"pipfile-spec": 6,
"requires": {
@ -135,17 +135,6 @@
"markers": "python_version >= '3.6'",
"version": "==3.0.1"
},
"ldap3": {
"hashes": [
"sha256:2bc966556fc4d4fa9f445a1c31dc484ee81d44a51ab0e2d0fd05b62cac75daa6",
"sha256:5630d1383e09ba94839e253e013f1aa1a2cf7a547628ba1265cb7b9a844b5687",
"sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70",
"sha256:5ab7febc00689181375de40c396dcad4f2659cd260fc5e94c508b6d77c17e9d5",
"sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f"
],
"index": "pypi",
"version": "==2.9.1"
},
"mako": {
"hashes": [
"sha256:17831f0b7087c313c0ffae2bcbbd3c1d5ba9eeac9c38f2eb7b50e8c99fe9d5ab",
@ -194,39 +183,13 @@
"markers": "python_version >= '3.6'",
"version": "==2.0.1"
},
"pyasn1": {
"hashes": [
"sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
"sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
"sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
"sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
"sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
"sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
"sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
"sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
"sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
"sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
"sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"
],
"version": "==0.4.8"
},
"pymysql": {
"hashes": [
"sha256:41fc3a0c5013d5f039639442321185532e3e2c8924687abe6537de157d403641",
"sha256:816927a350f38d56072aeca5dfb10221fe1dc653745853d30a216637f5d7ad36"
],
"index": "pypi",
"version": "==1.0.2"
},
"python-dateutil": {
"hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.2"
"version": "==2.8.1"
},
"python-dotenv": {
"hashes": [
@ -291,39 +254,39 @@
},
"sqlalchemy": {
"hashes": [
"sha256:09dbb4bc01a734ccddbf188deb2a69aede4b3c153a72b6d5c6900be7fb2945b1",
"sha256:12bac5fa1a6ea870bdccb96fe01610641dd44ebe001ed91ef7fcd980e9702db5",
"sha256:1fdae7d980a2fa617d119d0dc13ecb5c23cc63a8b04ffcb5298f2c59d86851e9",
"sha256:26daa429f039e29b1e523bf763bfab17490556b974c77b5ca7acb545b9230e9a",
"sha256:36a089dc604032d41343d86290ce85d4e6886012eea73faa88001260abf5ff81",
"sha256:39b5d36ab71f73c068cdcf70c38075511de73616e6c7fdd112d6268c2704d9f5",
"sha256:4014978de28163cd8027434916a92d0f5bb1a3a38dff5e8bf8bff4d9372a9117",
"sha256:44d23ea797a5e0be71bc5454b9ae99158ea0edc79e2393c6e9a2354de88329c0",
"sha256:488608953385d6c127d2dcbc4b11f8d7f2f30b89f6bd27c01b042253d985cc2f",
"sha256:5102b9face693e8b2db3b2539c7e1a5d9a5b4dc0d79967670626ffd2f710d6e6",
"sha256:5908ea6c652a050d768580d01219c98c071e71910ab8e7b42c02af4010608397",
"sha256:5d856cc50fd26fc8dd04892ed5a5a3d7eeb914fea2c2e484183e2d84c14926e0",
"sha256:68393d3fd31469845b6ba11f5b4209edbea0b58506be0e077aafbf9aa2e21e11",
"sha256:6a16c7c4452293da5143afa3056680db2d187b380b3ef4d470d4e29885720de3",
"sha256:756f5d2f5b92d27450167247fb574b09c4cd192a3f8c2e493b3e518a204ee543",
"sha256:891927a49b2363a4199763a9d436d97b0b42c65922a4ea09025600b81a00d17e",
"sha256:9bfe882d5a1bbde0245dca0bd48da0976bd6634cf2041d2fdf0417c5463e40e5",
"sha256:9fcbb4b4756b250ed19adc5e28c005b8ed56fdb5c21efa24c6822c0575b4964d",
"sha256:a00d9c6d3a8afe1d1681cd8a5266d2f0ed684b0b44bada2ca82403b9e8b25d39",
"sha256:a5e14cb0c0a4ac095395f24575a0e7ab5d1be27f5f9347f1762f21505e3ba9f1",
"sha256:b48148ceedfb55f764562e04c00539bb9ea72bf07820ca15a594a9a049ff6b0e",
"sha256:b7fb937c720847879c7402fe300cfdb2aeff22349fa4ea3651bca4e2d6555939",
"sha256:bc34a007e604091ca3a4a057525efc4cefd2b7fe970f44d20b9cfa109ab1bddb",
"sha256:c9373ef67a127799027091fa53449125351a8c943ddaa97bec4e99271dbb21f4",
"sha256:d09a760b0a045b4d799102ae7965b5491ccf102123f14b2a8cc6c01d1021a2d9",
"sha256:ec1be26cdccd60d180359a527d5980d959a26269a2c7b1b327a1eea0cab37ed8",
"sha256:eedd76f135461cf237534a6dc0d1e0f6bb88a1dc193678fab48a11d223462da5",
"sha256:f028ef6a1d828bc754852a022b2160e036202ac8658a6c7d34875aafd14a9a15",
"sha256:f814d80844969b0d22ea63663da4de5ca1c434cfbae226188901e5d368792c17",
"sha256:fd2102a8f8a659522719ed73865dff3d3cc76eb0833039dc473e0ad3041d04be"
"sha256:0f6d467b67a7e5048f1408e8ea60d6caa70be5b386d0eebbf1185ab49cb8c7e4",
"sha256:238d78b3110b7f7cffdb70bf9cda686e0d876a849bc78ba4d471aa7b1461f306",
"sha256:25c0e0f3a7e8c19350086b3c0fe93c4def045cec053d749ef15da710c4d54c81",
"sha256:2f60a2e599cf5cf5e5327ce60f2918b897e42ad9f405d10dd01e37869c0ce6fc",
"sha256:38ee3a266afef2978e82824650457f70c5d74ec0cadec1b10fe5ed6f038eb5d0",
"sha256:46361690f1e1c5385994a4caeb6e8126063ff593a5c635700bbc1245de793c1e",
"sha256:46b99eab618cdc1c871ea707b7c52edc23cfea6c750740cd242ba62b5c84de7f",
"sha256:4a67371752fd86d1d03a3b82d4e75404608f6f4d579b9676124079a22a40c79f",
"sha256:525dd3c2205b11a2bc6d770bf1ec63bde0253fd754b4c19c399d27ddc9dad0d3",
"sha256:6c8406c3d8c1c7d15da454de15d77f7bb48d14ede5db994f74226c348cf1050e",
"sha256:6da83225a23eaf7b3f48f3d5f53c91b2cf00fbfa48b24a7a758160112dd3e123",
"sha256:7150e5b543b466f45f668b352f7abda27998cc8035f051d1b7e9524ca9eb2f5f",
"sha256:76fbc24311a3d039d6cd147d396719f606d96d1413f3816c028a48e29367f646",
"sha256:854a7b15750e617e16f8d65dbc004f065a7963544b253b923f16109557648777",
"sha256:86c079732328f1add097b0b8079cd532b5d28e207fac93e9d6ea5f487506deef",
"sha256:8d860c62e3f51623ccd528d8fac44580501df557d4b467cc5581587fcf057719",
"sha256:9675d5bc7e4f96a7bb2b54d14e9b269a5fb6e5d36ecc7d01f0f65bb9af3185f9",
"sha256:9841762d114018c49483c089fa2d47f7e612e57666323f615913d7d7f46e9606",
"sha256:9eb25bcf9161e2fcbe9eebe8e829719b2334e849183f0e496bf4b83722bcccfa",
"sha256:aad3234a41340e9cf6184e621694e2a7233ba3f8aef9b1e6de8cba431b45ebd2",
"sha256:b502b5e2f08500cc4b8d29bfc4f51d805adcbc00f8d149e98fda8aae85ddb644",
"sha256:b86d83fefc8a8c394f3490c37e1953bc16c311a3d1d1cf91518793bfb9847fb4",
"sha256:c0eb2cd3ad4967fcbdd9e066e8cd91fe2c23c671dbae9952f0b4d3d42832cc5f",
"sha256:e0d48456e1aa4f0537f9c9af7be71e1f0659ff68bc1cd538ebc785f6b007bd0d",
"sha256:eaee5dd378f6f0d7c3ec49aeeb26564d55ac0ad73b9b4688bf29e66deabddf73",
"sha256:f14acb0fd16d404fda9370f93aace682f284340c89c3442ac747c5466ac7e2b5",
"sha256:f6fc526bd70898489d02bf52c8f0632ab377592ae954d0c0a5bb38d618dddaa9",
"sha256:fcd84e4d46a86291495d131a7824ba38d2e8278bda9425c50661a04633174319",
"sha256:ff38ecf89c69a531a7326c2dae71982edfe2f805f3c016cdc5bfd1a04ebf80cb",
"sha256:ff8bebc7a9d297dff2003460e01db2c20c63818b45fb19170f388b1a72fe5a14"
],
"index": "pypi",
"version": "==1.4.22"
"version": "==1.4.20"
},
"waitress": {
"hashes": [
@ -343,13 +306,12 @@
}
},
"develop": {
"backports.entry-points-selectable": {
"appdirs": {
"hashes": [
"sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a",
"sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
],
"markers": "python_version >= '2.7'",
"version": "==1.1.0"
"version": "==1.4.4"
},
"binaryornot": {
"hashes": [
@ -388,14 +350,6 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==4.0.0"
},
"charset-normalizer": {
"hashes": [
"sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b",
"sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"
],
"markers": "python_version >= '3'",
"version": "==2.0.4"
},
"distlib": {
"hashes": [
"sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736",
@ -420,19 +374,19 @@
},
"identify": {
"hashes": [
"sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839",
"sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"
"sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421",
"sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==2.2.11"
"version": "==2.2.10"
},
"idna": {
"hashes": [
"sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a",
"sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"markers": "python_version >= '3'",
"version": "==3.2"
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
},
"jinja2": {
"hashes": [
@ -503,14 +457,6 @@
],
"version": "==1.6.0"
},
"platformdirs": {
"hashes": [
"sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c",
"sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"
],
"markers": "python_version >= '3.6'",
"version": "==2.2.0"
},
"pre-commit": {
"hashes": [
"sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378",
@ -580,11 +526,11 @@
},
"requests": {
"hashes": [
"sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24",
"sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==2.26.0"
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.25.1"
},
"reuse": {
"hashes": [
@ -620,11 +566,11 @@
},
"virtualenv": {
"hashes": [
"sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094",
"sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"
"sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467",
"sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==20.7.0"
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.4.7"
},
"yapf": {
"hashes": [

View File

@ -68,18 +68,9 @@ flake8
### Testbenutzer
#### Lokal ohne LDAP
Für ein Login ohne LDAP werden die Benutzer aus der [`auth.yml`](./data/auth.yml) benutzt.
#### Lokal mit LDAP
Einen LDAP Server aufsetzen. Z.B. https://directory.apache.org/apacheds/
In der `.env` die LDAP Dinge ausfüllen (siehe [`env.dev`](./env.dev)).
### Beispiel-Requests
Beispiele brauchen curl und jq.
@ -122,15 +113,6 @@ curl -s \
http://localhost:5000/users/1/profile
```
Profilsuche nach Nickname:
```
curl -s \
-D "/dev/stderr" \
-H "Authorization: Bearer 22e6c5fc-8a5a-440e-b1f4-018deb9fd24e" \
http://localhost:5000/users/profiles
```
## Docker
@ -177,26 +159,6 @@ Für die Produktionsumgebung wird [waitress](https://docs.pylonsproject.org/proj
[`run_prod.py`](./run_prod.py) führt die DB Migrationen aus und startet den Server.
## Integrationsumgebung
Per [`docker-compose`](https://docs.docker.com/compose/) kann eine Integrationsumgebung gestartet werden.
Beide Projekte müssen nebeneinander ausgecheckt sein:
```
./
ki-backend
ki-frontend
```
Alles starten:
```
docker-compose up
```
Dann http://localhost:13337 aufrufen.
## Lizenzen
Dieses Projekt erfüllt die [REUSE](https://reuse.software/) Spezifikation.

19
app.py
View File

@ -8,36 +8,25 @@ import os
from dotenv import load_dotenv, find_dotenv
from flask import Flask
from flask_cors import CORS
from flask.logging import default_handler
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from ldap3.utils.log import logger as ldap3_logger
from ldap3.utils.log import set_library_log_detail_level, BASIC
from flask_migrate import Migrate
load_dotenv(find_dotenv())
app = Flask(__name__)
loglevel = os.getenv("KI_LOGLEVEL", logging.WARNING)
loglevel = int(loglevel)
app.logger.setLevel(loglevel)
logging.basicConfig(level=loglevel)
logging.debug("Hello from KI")
set_library_log_detail_level(BASIC)
ldap3_logger.addHandler(default_handler)
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv("SQLALCHEMY_DATABASE_URI")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["KI_DATA_DIR"] = os.path.dirname(__file__) + "/data"
app.config["CORS_ORIGINS"] = os.getenv("CORS_ORIGINS", "*")
app.config["KI_AUTH"] = os.getenv("KI_AUTH")
app.config["KI_LDAP_URL"] = os.getenv("KI_LDAP_URL")
app.config["KI_LDAP_ROOT_DN"] = os.getenv("KI_LDAP_ROOT_DN")
app.config["CORS_ORIGINS"] = os.getenv("CORS_ORIGINS", "*")
CORS(app)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
logging.debug("Hello from KI")
from ki import module # noqa

View File

@ -7,5 +7,3 @@ peter:
password: geheim
klaus:
password: jutta
dieter:
password: hunger

View File

@ -11,116 +11,3 @@ id,name
10,PostgreSQL
11,SQLite
12,Node.js
13,C++
14,C
15,VHDL
16,go
17,Perl
18,3D-Druck
19,ABAP
20,Android
21,Ansible
22,Arduino
23,Bash
24,batou
25,bind
26,Buchführung
27,C#
28,CAD
29,CAM
30,Cobol
31,CRM
32,CSS
33,D
34,Debian
35,Delphi
36,DevOPS
37,Discourse
38,Django
39,DNS
40,Dovecot
41,Elasticsearch
42,Emacs
43,Email
44,ERP
45,ESP
46,Excel
47,Fahrdienstleitung
48,Fedora
49,FLOSS
50,Geographie
51,Geologie
52,Gnome
53,GPS
54,Grafana
55,GrayLog
56,GSM
57,GTK
58,HTML
59,IBM Z
60,IMAP
61,Ionic
62,iOS
63,Java
64,Kryptographie
65,LDAP
66,LibreOffice
67,Linux
68,Literaturgeschichte
69,Lithographie
70,Lucene
71,Mailman
72,MariaDB
73,Markdown
74,Marketing
75,Microsoft Office
76,Monitoring
77,Nagios
78,nähen
79,NixOS
80,odoo
81,OpenOffice
82,OpenPGP
83,OpenStreetMap
84,openSUSE
85,Oracle
86,Percona
87,PGP
88,Plone
89,Postfix
90,PowerDNS
91,PowerPC
92,Projektmanagement
93,puppet
94,Qt
95,R/3
96,RedHat
97,RHEL
98,S/4
99,Salt
100,SAP
101,Scrum
102,ScummVM
103,Sensu
104,sh
105,Siemens S5
106,Siemens S7
107,Simatic
108,SLES
109,Spring
110,SQL
111,Stenographie
112,Steuerrecht
113,Teppich knüpfen
114,TeX
115,Verlagswesen
116,vi(m)
117,Windows 10
118,Windows 2000
119,Windows 2012R2
120,Windows 7
121,Windows NT
122,Windows XP
123,x86
124,Zope
125,zsh

1 id name
11 10 PostgreSQL
12 11 SQLite
13 12 Node.js
13 C++
14 C
15 VHDL
16 go
17 Perl
18 3D-Druck
19 ABAP
20 Android
21 Ansible
22 Arduino
23 Bash
24 batou
25 bind
26 Buchführung
27 C#
28 CAD
29 CAM
30 Cobol
31 CRM
32 CSS
33 D
34 Debian
35 Delphi
36 DevOPS
37 Discourse
38 Django
39 DNS
40 Dovecot
41 Elasticsearch
42 Emacs
43 Email
44 ERP
45 ESP
46 Excel
47 Fahrdienstleitung
48 Fedora
49 FLOSS
50 Geographie
51 Geologie
52 Gnome
53 GPS
54 Grafana
55 GrayLog
56 GSM
57 GTK
58 HTML
59 IBM Z
60 IMAP
61 Ionic
62 iOS
63 Java
64 Kryptographie
65 LDAP
66 LibreOffice
67 Linux
68 Literaturgeschichte
69 Lithographie
70 Lucene
71 Mailman
72 MariaDB
73 Markdown
74 Marketing
75 Microsoft Office
76 Monitoring
77 Nagios
78 nähen
79 NixOS
80 odoo
81 OpenOffice
82 OpenPGP
83 OpenStreetMap
84 openSUSE
85 Oracle
86 Percona
87 PGP
88 Plone
89 Postfix
90 PowerDNS
91 PowerPC
92 Projektmanagement
93 puppet
94 Qt
95 R/3
96 RedHat
97 RHEL
98 S/4
99 Salt
100 SAP
101 Scrum
102 ScummVM
103 Sensu
104 sh
105 Siemens S5
106 Siemens S7
107 Simatic
108 SLES
109 Spring
110 SQL
111 Stenographie
112 Steuerrecht
113 Teppich knüpfen
114 TeX
115 Verlagswesen
116 vi(m)
117 Windows 10
118 Windows 2000
119 Windows 2012R2
120 Windows 7
121 Windows NT
122 Windows XP
123 x86
124 Zope
125 zsh

View File

@ -1,22 +0,0 @@
# SPDX-FileCopyrightText: 2021 WTF Kooperative eG <https://wtf-eg.de/>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
version: "3"
services:
ki_backend:
build: .
restart: "no"
ports:
- "13338:5000"
volumes:
- "./env.dev:/app/.env"
ki_frontend:
build: ./../ki-frontend
restart: "no"
ports:
- "13337:80"
volumes:
- "../ki-frontend/public/config.js.int:/user/share/nginx/html/config.js"

View File

@ -9,12 +9,7 @@ CORS_ORIGINS=*
FLASK_APP=app.py
FLASK_ENV=development
# auth method: file or ldap
KI_AUTH=file
# ldap auth only
KI_LDAP_URL=ldap://localhost:10389
KI_LDAP_ROOT_DN=dc=example,dc=com
# 10 = debug
KI_LOGLEVEL=10

View File

@ -26,37 +26,12 @@ def seed_contacttypes():
db.session.add(ContactType(id=int(contacttype["id"]), name=contacttype["name"]))
def seed_user(nickname, visible=False, skills=[], languages=[], volunteerwork="", availability="", freetext=""):
app.logger.info(f"seeding {nickname} \\o/")
user = User(auth_id=nickname)
db.session.add(user)
profile = Profile(nickname=nickname,
pronouns="",
volunteerwork=volunteerwork,
availability=availability,
freetext=freetext,
visible=visible,
user=user)
for skill_data in skills:
skill = ProfileSkill(profile=profile, skill_id=skill_data[0], level=skill_data[1])
db.session.add(skill)
for language_data in languages:
language = ProfileLanguage(profile=profile, language_id=language_data[0], level=language_data[1])
db.session.add(language)
db.session.add(profile)
def seed(dev: bool):
seed_contacttypes()
skill_seed_file_path = app.config["KI_DATA_DIR"] + "/seed_data/skills.csv"
app.logger.info("importing skills")
logging.info("importing skills")
with open(skill_seed_file_path) as skills_file:
skills_csv_reader = csv.DictReader(skills_file)
@ -68,7 +43,7 @@ def seed(dev: bool):
if db_skill is None:
db.session.add(Skill(id=int(skill["id"]), name=skill["name"]))
app.logger.info("importing languages")
logging.info("importing languages")
iso_seed_file_path = app.config["KI_DATA_DIR"] + "/seed_data/iso_639_1.csv"
@ -83,7 +58,7 @@ def seed(dev: bool):
db.session.add(Language(id=iso["639-1"], name=iso["Sprache"]))
if dev:
app.logger.info("seeding peter :)")
logging.info("seeding peter :)")
peter = User(auth_id="peter")
db.session.add(peter)
@ -91,7 +66,6 @@ def seed(dev: bool):
peters_profile = Profile(nickname="peternichtlustig",
pronouns="Herr Dr. Dr.",
volunteerwork="Gartenverein",
availability="Immer",
freetext="Ich mag Kaffee",
user=peter)
db.session.add(peters_profile)
@ -130,21 +104,9 @@ def seed(dev: bool):
peter_fr = ProfileLanguage(profile=peters_profile, language_id="fr", level=3)
db.session.add(peter_fr)
seed_user("klaus")
logging.info("seeding klaus :D")
seed_user("dirtydieter",
visible=True,
volunteerwork="Müll sammeln",
availability="Nur nachts",
freetext="1001010010111!!!",
skills=[(Skill.skill_id_php, 5)])
seed_user("jutta",
visible=True,
languages=[("fr", 5)],
skills=[(Skill.skill_id_php, 3), (Skill.skill_id_mysql, 4)])
seed_user("giesela", visible=True, skills=[(Skill.skill_id_mysql, 3), (Skill.skill_id_postgresql, 5)])
seed_user("bertha", visible=False, skills=[(Skill.skill_id_sqlite, 3), (Skill.skill_id_postgresql, 5)])
seed_user("monique", visible=True, languages=[("fr", 4)])
klaus = User(auth_id="klaus")
db.session.add(klaus)
db.session.commit()

View File

@ -5,28 +5,11 @@
import uuid
import yaml
from ldap3 import Server, Connection, ALL
from app import app, db
from ki.models import User, Token
def create_user_token(username):
user = User.query.filter(User.auth_id.__eq__(username)).first()
if user is None:
user = User(auth_id=username)
db.session.add(user)
token = Token(token=str(uuid.uuid4()), user=user)
db.session.add(token)
db.session.commit()
return token
def file_auth(username, password):
app.logger.debug("performing file authentication")
def auth(username, password):
auth_file_path = app.config["KI_DATA_DIR"] + "/auth.yml"
with open(auth_file_path, "r") as auth_file_stream:
@ -40,32 +23,14 @@ def file_auth(username, password):
if auth_user["password"] != password:
return None
return create_user_token(username)
user = User.query.filter(User.auth_id.__eq__(username)).first()
if user is None:
user = User(auth_id=username)
db.session.add(user)
def ldap_auth(username, password):
app.logger.debug("performing LDAP authentication")
token = Token(token=str(uuid.uuid4()), user=user)
db.session.add(token)
db.session.commit()
server = Server(app.config['KI_LDAP_URL'], get_info=ALL)
root_dn = app.config['KI_LDAP_ROOT_DN']
ldap_user = f"cn={username},{root_dn}"
app.logger.debug(f"server: {server}")
connection = Connection(server, user=ldap_user, password=password)
if connection.bind():
connection.unbind()
return create_user_token(username)
connection.unbind()
return None
def auth(username, password):
if app.config['KI_AUTH'] == 'file':
return file_auth(username, password)
if app.config['KI_AUTH'] == 'ldap':
return ldap_auth(username, password)
raise RuntimeError('unknown auth method')
return token

View File

@ -2,5 +2,4 @@
#
# SPDX-License-Identifier: AGPL-3.0-or-later
from ki.handlers.find_profiles import find_profiles # noqa
from ki.handlers.update_profile import update_profile # noqa

View File

@ -1,44 +0,0 @@
# SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
from flask import make_response, request
from ki.models import Profile, ProfileSkill, Skill, ProfileLanguage, Language
def find_profiles():
page = int(request.args.get("page", 1))
if page < 1:
return make_response({"messages": {"page": "Die angefragte Seite muss mindestens 1 sein"}}, 400)
page_size = int(request.args.get("page_size", 20))
if page_size > 100:
return make_response({"messages": {"page_size": "Die maximale Anzahl Einträge pro Seite beträgt 100"}}, 400)
query = Profile.query.filter(Profile.visible.is_(True)) \
.join(Profile.skills, isouter=True).join(ProfileSkill.skill, isouter=True) \
.join(Profile.languages, isouter=True).join(ProfileLanguage.language, isouter=True)
if "search" in request.args:
terms = request.args["search"].split(" ")
for term in terms:
query = query.filter(
Profile.nickname.like(f"%{term}%") | Skill.name.like(f"%{term}%") | Language.name.like(f"%{term}%"))
if "nickname" in request.args:
nickname = request.args.get("nickname")
query = query.filter(Profile.nickname.like(f"%{nickname}%"))
count = query.distinct(Profile.id).count()
offset = (page - 1) * page_size
db_profiles = query.limit(page_size).offset(offset).all()
api_profiles = []
for db_profile in db_profiles:
api_profiles.append(db_profile.to_dict())
return make_response({"total": count, "profiles": api_profiles})

View File

@ -135,7 +135,6 @@ def update_profile(user_id: int):
profile.pronouns = request.json.get("pronouns", "")
profile.volunteerwork = request.json.get("volunteerwork", "")
profile.availability = request.json.get("availability", "")
profile.freetext = request.json.get("freetext", "")
profile.visible = request.json.get("visible", False)

View File

@ -32,7 +32,6 @@ class Profile(db.Model):
pronouns = Column(String(25), default="")
volunteerwork = Column(String(4000), default="")
freetext = Column(String(4000), default="")
availability = Column(String(4000), default="")
visible = Column(Boolean, nullable=False, default=False)
created = Column(DateTime, nullable=False, default=datetime.now)
updated = Column(DateTime, onupdate=datetime.now, nullable=False, default=datetime.now)
@ -50,10 +49,9 @@ class Profile(db.Model):
"nickname": self.nickname,
"pronouns": self.pronouns,
"volunteerwork": self.volunteerwork,
"availability": self.availability,
"freetext": self.freetext,
"visible": self.visible,
"address": self.address.to_dict() if self.address else None,
"address": self.address.to_dict(),
"contacts": list(map(lambda contact: contact.to_dict(), self.contacts)),
"skills": list(map(lambda skill: skill.to_dict(), self.skills)),
"searchtopics": list(map(lambda searchtopic: searchtopic.to_dict(), self.searchtopics)),
@ -133,13 +131,6 @@ class Address(db.Model):
class Skill(db.Model):
skill_id_php = 1
skill_id_python = 3
skill_id_sqlalchemy = 7
skill_id_mysql = 9
skill_id_postgresql = 10
skill_id_sqlite = 11
__tablename__ = "skill"
id = Column(Integer, primary_key=True)
@ -153,19 +144,6 @@ class Skill(db.Model):
class ProfileSkill(db.Model):
level1_text = "bis 6 Monate"
level2_text = "bis 1 Jahr"
level3_text = "bis 3 Jahre"
level4_text = "bis 5 Jahre"
level5_text = "mehr als 5 Jahre"
level_texts = {
1: level1_text,
2: level2_text,
3: level3_text,
4: level4_text,
5: level5_text,
}
__tablename__ = "profile_skill"
profile_id = Column(Integer, ForeignKey("profile.id"), primary_key=True)
@ -195,8 +173,8 @@ class ProfileSearchtopic(db.Model):
class Language(db.Model):
__tablename__ = "language"
id = Column(String(4), primary_key=True)
name = Column(String(100), nullable=False)
id = Column(String(2), primary_key=True)
name = Column(String(25), nullable=False)
profiles = relationship("ProfileLanguage", back_populates="language")
@ -205,23 +183,10 @@ class Language(db.Model):
class ProfileLanguage(db.Model):
level1_text = "keine Angabe"
level2_text = "Grundkenntnisse"
level3_text = "Gut"
level4_text = "Fließend"
level5_text = "Muttersprache"
level_texts = {
1: level1_text,
2: level2_text,
3: level3_text,
4: level4_text,
5: level5_text,
}
__tablename__ = "profile_language"
profile_id = Column(Integer, ForeignKey("profile.id"), primary_key=True)
language_id = Column(String(4), ForeignKey("language.id"), primary_key=True)
language_id = Column(Integer, ForeignKey("language.id"), primary_key=True)
level = Column(SmallInteger, nullable=False)
profile = relationship("Profile", back_populates="languages")

View File

@ -7,14 +7,10 @@ from flask import g, make_response, request, send_file
from functools import wraps
from ki.auth import auth
from ki.handlers import find_profiles as find_profiles_handler
from ki.handlers import update_profile as update_profile_handler
from ki.models import ContactType, Language, Skill, Token, User
from app import app
content_type_svg = "image/svg+xml"
content_type_png = "image/png"
def token_auth(func):
@wraps(func)
@ -74,22 +70,22 @@ def handle_icon_request(model, id, path):
icon_svg_path = icon_base_path + ".svg"
if os.path.exists(icon_svg_path):
return send_file(icon_svg_path, mimetype=content_type_svg)
return send_file(icon_svg_path, mimetype="image/svg")
icon_png_path = icon_base_path + ".png"
if os.path.exists(icon_png_path):
return send_file(icon_png_path, mimetype=content_type_png)
return send_file(icon_png_path, mimetype="image/png")
unknown_svg_path = path + "unknown.svg"
if os.path.exists(unknown_svg_path):
return send_file(unknown_svg_path, mimetype=content_type_svg)
return send_file(unknown_svg_path, mimetype="image/svg")
unknown_png_path = path + "unknown.png"
if os.path.exists(unknown_png_path):
return send_file(unknown_png_path, mimetype=content_type_png)
return send_file(unknown_png_path, mimetype="image/png")
return make_response({"error": "icon not found"}, 404)
@ -147,12 +143,6 @@ def get_contacttypes():
return handle_completion_request(ContactType, "contacttypes")
@app.route("/users/profiles")
@token_auth
def find_profiles():
return find_profiles_handler()
@app.route("/skills")
@token_auth
def get_skills():

View File

@ -8,7 +8,6 @@ import unittest
from app import app, db, migrate
from ki.actions import seed
from ki.models import Skill
class ApiTest(unittest.TestCase):
@ -16,10 +15,8 @@ class ApiTest(unittest.TestCase):
def setUp(self):
app.debug = True
app.config["KI_AUTH"] = "file"
app.config["TESTING"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
self.client = app.test_client()
with app.app_context():
@ -27,8 +24,6 @@ class ApiTest(unittest.TestCase):
command.upgrade(config, "head")
seed(True)
max_skill = Skill.query.order_by(Skill.id.desc()).first()
self.max_skill_id = max_skill.id
def tearDown(self):
db.drop_all()

View File

@ -1,92 +0,0 @@
# SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import unittest
from ki.test.ApiTest import ApiTest
class TestFindProfilesEndpoint(ApiTest):
def test_find_profiles_options(self):
response = self.client.options("/users/profiles")
self.assertEqual(response.status_code, 200)
self.assertIn("Access-Control-Allow-Origin", response.headers)
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
def test_find_nobody(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?nickname=horsthorsthorst",
headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"total": 0, "profiles": []})
def test_find_sql_specialchars(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?nickname=%22%27%25", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"total": 0, "profiles": []})
def test_find_all(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({"total": 4}, response.json)
self.assertDictContainsSubset({"nickname": "dirtydieter"}, response.json["profiles"][0])
def test_find_dieter(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?search=dieter%20php", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({"total": 1}, response.json)
self.assertDictContainsSubset({"nickname": "dirtydieter"}, response.json["profiles"][0])
def test_not_find_dieter(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?search=dieter%20sqlite",
headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({"total": 0}, response.json)
def test_find_sql(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?search=sql", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({"total": 2}, response.json)
self.assertDictContainsSubset({"nickname": "jutta"}, response.json["profiles"][0])
self.assertDictContainsSubset({"nickname": "giesela"}, response.json["profiles"][1])
def test_find_postgres(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?search=post", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({"total": 1}, response.json)
self.assertDictContainsSubset({"nickname": "giesela"}, response.json["profiles"][0])
def test_find_php_franzosen(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?search=php%20franz", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({"total": 1}, response.json)
self.assertDictContainsSubset({"nickname": "jutta"}, response.json["profiles"][0])
def test_find_franzosen(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/profiles?search=französisch", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertDictContainsSubset({"total": 2}, response.json)
self.assertDictContainsSubset({"nickname": "jutta"}, response.json["profiles"][0])
self.assertDictContainsSubset({"nickname": "monique"}, response.json["profiles"][1])
if __name__ == "main":
unittest.main()

View File

@ -1,38 +0,0 @@
# SPDX-FileCopyrightText: WTF Kooperative eG <https://wtf-eg.de/>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import unittest
from ki.test.ApiTest import ApiTest
class TestLanguagesEndpoint(ApiTest):
def test_skills_options(self):
response = self.client.options("/languages")
self.assertEqual(response.status_code, 200)
self.assertIn("Access-Control-Allow-Origin", response.headers)
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
def test_search_languages_fr(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/languages?search=fr", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertEqual({"languages": [{
"id": "fr",
"name": "Französisch",
"icon_url": "/languages/fr/icon"
}]}, response.json)
self.assertIn("Access-Control-Allow-Origin", response.headers)
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
def test_get_fr_icon(self):
response = self.client.get("/languages/fr/icon")
self.assertEqual(response.status_code, 200)
self.assertIn("Content-Type", response.headers)
self.assertEqual(response.headers["Content-Type"], "image/png")
if __name__ == "main":
unittest.main()

View File

@ -33,7 +33,6 @@ class TestProfileEndpoint(ApiTest):
data = {
"pronouns": "Monsieur",
"volunteerwork": "ja",
"availability": "Nie",
"freetext": "Hallo",
"visible": True,
"address": {
@ -110,7 +109,6 @@ class TestProfileEndpoint(ApiTest):
profile = user.profile
self.assertEqual("Monsieur", profile.pronouns)
self.assertEqual("ja", profile.volunteerwork)
self.assertEqual("Nie", profile.availability)
self.assertEqual("Hallo", profile.freetext)
self.assertTrue(profile.visible)
@ -143,7 +141,7 @@ class TestProfileEndpoint(ApiTest):
self.assertEqual(first_skill.level, 4)
second_skill = skills[1]
self.assertEqual(second_skill.skill.id, self.max_skill_id + 1)
self.assertEqual(second_skill.skill.id, 13)
self.assertEqual(second_skill.skill.name, "Tschunkproduktion")
self.assertEqual(second_skill.level, 5)
@ -155,7 +153,7 @@ class TestProfileEndpoint(ApiTest):
self.assertEqual(first_searchtopic.skill.name, "Python")
second_searchtopic = searchtopics[1]
self.assertEqual(second_searchtopic.skill.id, self.max_skill_id + 2)
self.assertEqual(second_searchtopic.skill.id, 14)
self.assertEqual(second_searchtopic.skill.name, "Assembler")
languages = profile.languages
@ -174,14 +172,7 @@ class TestProfileEndpoint(ApiTest):
response = self.client.get("/users/1/profile")
self.assertEqual(response.status_code, 401)
def test_get_visible_proifle(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/users/3/profile", headers={"Authorization": f"Bearer {token}"})
self.assertEqual(response.status_code, 200)
def test_get_own_profile(self):
def test_get_profile(self):
login_data = {"username": "peter", "password": "geheim"}
login_response = self.client.post("/users/login", data=json.dumps(login_data), content_type="application/json")
@ -198,7 +189,6 @@ class TestProfileEndpoint(ApiTest):
"user_id": 1,
"nickname": "peternichtlustig",
"pronouns": "Herr Dr. Dr.",
"availability": "Immer",
"freetext": "Ich mag Kaffee",
"volunteerwork": "Gartenverein",
"visible": False,

View File

@ -14,39 +14,30 @@ class TestSkillsEndpoint(ApiTest):
self.assertIn("Access-Control-Allow-Origin", response.headers)
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
def test_find_skills_php(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/skills?search=php", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.json['skills']), 1)
self.assertIn({"id": 1, "name": "PHP", "icon_url": "/skills/1/icon"}, response.json['skills'])
self.assertIn("Access-Control-Allow-Origin", response.headers)
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
def test_find_skills_p(self):
def test_get_skills1(self):
token = self.login("peter", "geheim")["token"]
response = self.client.get("/skills?search=p", headers={"Authorization": "Bearer " + token})
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.json['skills']), 3)
self.assertEqual(
{
"skills": [{
"id": 1,
"name": "PHP",
"icon_url": "/skills/1/icon"
}, {
"id": 10,
"name": "PostgreSQL",
"icon_url": "/skills/10/icon"
}, {
"id": 3,
"name": "Python",
"icon_url": "/skills/3/icon"
}]
}, response.json)
self.assertIn("Access-Control-Allow-Origin", response.headers)
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*")
def test_get_php_skill_icon(self):
response = self.client.get("/skills/1/icon")
self.assertEqual(response.status_code, 200)
self.assertIn("Content-Type", response.headers)
self.assertEqual(response.headers["Content-Type"], "image/svg+xml; charset=utf-8")
def test_get_fallback_skill_icon(self):
response = self.client.get("/skills/2/icon")
self.assertEqual(response.status_code, 200)
self.assertIn("Content-Type", response.headers)
self.assertEqual(response.headers["Content-Type"], "image/svg+xml; charset=utf-8")
if __name__ == "main":
unittest.main()

View File

@ -1,8 +1,8 @@
"""Initial migration.
Revision ID: 9183e2335b05
Revision ID: 1e1b2be49756
Revises:
Create Date: 2021-08-02 21:51:30.400680
Create Date: 2021-07-05 20:36:04.047270
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9183e2335b05'
revision = '1e1b2be49756'
down_revision = None
branch_labels = None
depends_on = None
@ -24,8 +24,8 @@ def upgrade():
sa.PrimaryKeyConstraint('id')
)
op.create_table('language',
sa.Column('id', sa.String(length=4), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('id', sa.String(length=2), nullable=False),
sa.Column('name', sa.String(length=25), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('profile',
@ -34,7 +34,6 @@ def upgrade():
sa.Column('pronouns', sa.String(length=25), nullable=True),
sa.Column('volunteerwork', sa.String(length=4000), nullable=True),
sa.Column('freetext', sa.String(length=4000), nullable=True),
sa.Column('availability', sa.String(length=4000), nullable=True),
sa.Column('visible', sa.Boolean(), nullable=False),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
@ -71,7 +70,7 @@ def upgrade():
)
op.create_table('profile_language',
sa.Column('profile_id', sa.Integer(), nullable=False),
sa.Column('language_id', sa.String(length=4), nullable=False),
sa.Column('language_id', sa.Integer(), nullable=False),
sa.Column('level', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['language_id'], ['language.id'], ),
sa.ForeignKeyConstraint(['profile_id'], ['profile.id'], ),