jims-backoffice 0.1.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ JIMS_DB_CONN_URI="postgresql://user:password@localhost:5432"
@@ -0,0 +1,499 @@
1
+ # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
2
+ # Created by https://www.toptal.com/developers/gitignore/api/windows,python,pycharm+iml,pycharm+all,pycharm,macos,linux,visualstudiocode
3
+ # Edit at https://www.toptal.com/developers/gitignore?templates=windows,python,pycharm+iml,pycharm+all,pycharm,macos,linux,visualstudiocode
4
+
5
+ ### Linux ###
6
+ *~
7
+
8
+ # temporary files which can be created if a process still has a handle open of a deleted file
9
+ .fuse_hidden*
10
+
11
+ # KDE directory preferences
12
+ .directory
13
+
14
+ # Linux trash folder which might appear on any partition or disk
15
+ .Trash-*
16
+
17
+ # .nfs files are created when an open file is removed but is still being accessed
18
+ .nfs*
19
+
20
+ ### macOS ###
21
+ # General
22
+ .DS_Store
23
+ .AppleDouble
24
+ .LSOverride
25
+
26
+ # Icon must end with two \r
27
+ Icon
28
+
29
+ # Thumbnails
30
+ ._*
31
+
32
+ # Files that might appear in the root of a volume
33
+ .DocumentRevisions-V100
34
+ .fseventsd
35
+ .Spotlight-V100
36
+ .TemporaryItems
37
+ .Trashes
38
+ .VolumeIcon.icns
39
+ .com.apple.timemachine.donotpresent
40
+
41
+ # Directories potentially created on remote AFP share
42
+ .AppleDB
43
+ .AppleDesktop
44
+ Network Trash Folder
45
+ Temporary Items
46
+ .apdisk
47
+
48
+ ### macOS Patch ###
49
+ # iCloud generated files
50
+ *.icloud
51
+
52
+ ### PyCharm ###
53
+ # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
54
+ # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
55
+
56
+ # User-specific stuff
57
+ .idea/**/workspace.xml
58
+ .idea/**/tasks.xml
59
+ .idea/**/usage.statistics.xml
60
+ .idea/**/dictionaries
61
+ .idea/**/shelf
62
+
63
+ # AWS User-specific
64
+ .idea/**/aws.xml
65
+
66
+ # Generated files
67
+ .idea/**/contentModel.xml
68
+
69
+ # Sensitive or high-churn files
70
+ .idea/**/dataSources/
71
+ .idea/**/dataSources.ids
72
+ .idea/**/dataSources.local.xml
73
+ .idea/**/sqlDataSources.xml
74
+ .idea/**/dynamic.xml
75
+ .idea/**/uiDesigner.xml
76
+ .idea/**/dbnavigator.xml
77
+
78
+ # Gradle
79
+ .idea/**/gradle.xml
80
+ .idea/**/libraries
81
+
82
+ # Gradle and Maven with auto-import
83
+ # When using Gradle or Maven with auto-import, you should exclude module files,
84
+ # since they will be recreated, and may cause churn. Uncomment if using
85
+ # auto-import.
86
+ # .idea/artifacts
87
+ # .idea/compiler.xml
88
+ # .idea/jarRepositories.xml
89
+ # .idea/modules.xml
90
+ # .idea/*.iml
91
+ # .idea/modules
92
+ # *.iml
93
+ # *.ipr
94
+
95
+ # CMake
96
+ cmake-build-*/
97
+
98
+ # Mongo Explorer plugin
99
+ .idea/**/mongoSettings.xml
100
+
101
+ # File-based project format
102
+ *.iws
103
+
104
+ # IntelliJ
105
+ out/
106
+
107
+ # mpeltonen/sbt-idea plugin
108
+ .idea_modules/
109
+
110
+ # JIRA plugin
111
+ atlassian-ide-plugin.xml
112
+
113
+ # Cursive Clojure plugin
114
+ .idea/replstate.xml
115
+
116
+ # SonarLint plugin
117
+ .idea/sonarlint/
118
+
119
+ # Crashlytics plugin (for Android Studio and IntelliJ)
120
+ com_crashlytics_export_strings.xml
121
+ crashlytics.properties
122
+ crashlytics-build.properties
123
+ fabric.properties
124
+
125
+ # Editor-based Rest Client
126
+ .idea/httpRequests
127
+
128
+ # Android studio 3.1+ serialized cache file
129
+ .idea/caches/build_file_checksums.ser
130
+
131
+ ### PyCharm Patch ###
132
+ # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
133
+
134
+ # *.iml
135
+ # modules.xml
136
+ # .idea/misc.xml
137
+ # *.ipr
138
+
139
+ # Sonarlint plugin
140
+ # https://plugins.jetbrains.com/plugin/7973-sonarlint
141
+ .idea/**/sonarlint/
142
+
143
+ # SonarQube Plugin
144
+ # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
145
+ .idea/**/sonarIssues.xml
146
+
147
+ # Markdown Navigator plugin
148
+ # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
149
+ .idea/**/markdown-navigator.xml
150
+ .idea/**/markdown-navigator-enh.xml
151
+ .idea/**/markdown-navigator/
152
+
153
+ # Cache file creation bug
154
+ # See https://youtrack.jetbrains.com/issue/JBR-2257
155
+ .idea/$CACHE_FILE$
156
+
157
+ # CodeStream plugin
158
+ # https://plugins.jetbrains.com/plugin/12206-codestream
159
+ .idea/codestream.xml
160
+
161
+ # Azure Toolkit for IntelliJ plugin
162
+ # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
163
+ .idea/**/azureSettings.xml
164
+
165
+ ### PyCharm+all ###
166
+ # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
167
+ # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
168
+
169
+ # User-specific stuff
170
+
171
+ # AWS User-specific
172
+
173
+ # Generated files
174
+
175
+ # Sensitive or high-churn files
176
+
177
+ # Gradle
178
+
179
+ # Gradle and Maven with auto-import
180
+ # When using Gradle or Maven with auto-import, you should exclude module files,
181
+ # since they will be recreated, and may cause churn. Uncomment if using
182
+ # auto-import.
183
+ # .idea/artifacts
184
+ # .idea/compiler.xml
185
+ # .idea/jarRepositories.xml
186
+ # .idea/modules.xml
187
+ # .idea/*.iml
188
+ # .idea/modules
189
+ # *.iml
190
+ # *.ipr
191
+
192
+ # CMake
193
+
194
+ # Mongo Explorer plugin
195
+
196
+ # File-based project format
197
+
198
+ # IntelliJ
199
+
200
+ # mpeltonen/sbt-idea plugin
201
+
202
+ # JIRA plugin
203
+
204
+ # Cursive Clojure plugin
205
+
206
+ # SonarLint plugin
207
+
208
+ # Crashlytics plugin (for Android Studio and IntelliJ)
209
+
210
+ # Editor-based Rest Client
211
+
212
+ # Android studio 3.1+ serialized cache file
213
+
214
+ ### PyCharm+all Patch ###
215
+ # Ignore everything but code style settings and run configurations
216
+ # that are supposed to be shared within teams.
217
+
218
+ .idea/*
219
+
220
+ !.idea/codeStyles
221
+ !.idea/runConfigurations
222
+
223
+ ### PyCharm+iml ###
224
+ # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
225
+ # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
226
+
227
+ # User-specific stuff
228
+
229
+ # AWS User-specific
230
+
231
+ # Generated files
232
+
233
+ # Sensitive or high-churn files
234
+
235
+ # Gradle
236
+
237
+ # Gradle and Maven with auto-import
238
+ # When using Gradle or Maven with auto-import, you should exclude module files,
239
+ # since they will be recreated, and may cause churn. Uncomment if using
240
+ # auto-import.
241
+ # .idea/artifacts
242
+ # .idea/compiler.xml
243
+ # .idea/jarRepositories.xml
244
+ # .idea/modules.xml
245
+ # .idea/*.iml
246
+ # .idea/modules
247
+ # *.iml
248
+ # *.ipr
249
+
250
+ # CMake
251
+
252
+ # Mongo Explorer plugin
253
+
254
+ # File-based project format
255
+
256
+ # IntelliJ
257
+
258
+ # mpeltonen/sbt-idea plugin
259
+
260
+ # JIRA plugin
261
+
262
+ # Cursive Clojure plugin
263
+
264
+ # SonarLint plugin
265
+
266
+ # Crashlytics plugin (for Android Studio and IntelliJ)
267
+
268
+ # Editor-based Rest Client
269
+
270
+ # Android studio 3.1+ serialized cache file
271
+
272
+ ### PyCharm+iml Patch ###
273
+ # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
274
+
275
+ *.iml
276
+ modules.xml
277
+ .idea/misc.xml
278
+ *.ipr
279
+
280
+ ### Python ###
281
+ # Byte-compiled / optimized / DLL files
282
+ __pycache__/
283
+ *.py[cod]
284
+ *$py.class
285
+
286
+ # C extensions
287
+ *.so
288
+
289
+ # Distribution / packaging
290
+ .Python
291
+ build/
292
+ develop-eggs/
293
+ dist/
294
+ downloads/
295
+ eggs/
296
+ .eggs/
297
+ lib/
298
+ lib64/
299
+ parts/
300
+ sdist/
301
+ var/
302
+ wheels/
303
+ share/python-wheels/
304
+ *.egg-info/
305
+ .installed.cfg
306
+ *.egg
307
+ MANIFEST
308
+
309
+ # PyInstaller
310
+ # Usually these files are written by a python script from a template
311
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
312
+ *.manifest
313
+ *.spec
314
+
315
+ # Installer logs
316
+ pip-log.txt
317
+ pip-delete-this-directory.txt
318
+
319
+ # Unit test / coverage reports
320
+ htmlcov/
321
+ .tox/
322
+ .nox/
323
+ .coverage
324
+ .coverage.*
325
+ .cache
326
+ nosetests.xml
327
+ coverage.xml
328
+ *.cover
329
+ *.py,cover
330
+ .hypothesis/
331
+ .pytest_cache/
332
+ cover/
333
+
334
+ # Translations
335
+ *.mo
336
+ *.pot
337
+
338
+ # Django stuff:
339
+ *.log
340
+ local_settings.py
341
+ db.sqlite3
342
+ db.sqlite3-journal
343
+
344
+ # Flask stuff:
345
+ instance/
346
+ .webassets-cache
347
+
348
+ # Scrapy stuff:
349
+ .scrapy
350
+
351
+ # Sphinx documentation
352
+ docs/_build/
353
+
354
+ # PyBuilder
355
+ .pybuilder/
356
+ target/
357
+
358
+ # Jupyter Notebook
359
+ .ipynb_checkpoints
360
+
361
+ # IPython
362
+ profile_default/
363
+ ipython_config.py
364
+
365
+ # pyenv
366
+ # For a library or package, you might want to ignore these files since the code is
367
+ # intended to run in multiple environments; otherwise, check them in:
368
+ # .python-version
369
+
370
+ # pipenv
371
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
372
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
373
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
374
+ # install all needed dependencies.
375
+ #Pipfile.lock
376
+
377
+ # poetry
378
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
379
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
380
+ # commonly ignored for libraries.
381
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
382
+ #poetry.lock
383
+
384
+ # pdm
385
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
386
+ #pdm.lock
387
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
388
+ # in version control.
389
+ # https://pdm.fming.dev/#use-with-ide
390
+ .pdm.toml
391
+
392
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
393
+ __pypackages__/
394
+
395
+ # Celery stuff
396
+ celerybeat-schedule
397
+ celerybeat.pid
398
+
399
+ # SageMath parsed files
400
+ *.sage.py
401
+
402
+ # Environments
403
+ .env
404
+ .venv
405
+ env/
406
+ venv/
407
+ ENV/
408
+ env.bak/
409
+ venv.bak/
410
+
411
+ # Spyder project settings
412
+ .spyderproject
413
+ .spyproject
414
+
415
+ # Rope project settings
416
+ .ropeproject
417
+
418
+ # mkdocs documentation
419
+ /site
420
+
421
+ # mypy
422
+ .mypy_cache/
423
+ .dmypy.json
424
+ dmypy.json
425
+
426
+ # Pyre type checker
427
+ .pyre/
428
+
429
+ # pytype static type analyzer
430
+ .pytype/
431
+
432
+ # Cython debug symbols
433
+ cython_debug/
434
+
435
+ # PyCharm
436
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
437
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
438
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
439
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
440
+ #.idea/
441
+
442
+ ### Python Patch ###
443
+ # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
444
+ poetry.toml
445
+
446
+ # ruff
447
+ .ruff_cache/
448
+
449
+ # LSP config files
450
+ pyrightconfig.json
451
+
452
+ ### VisualStudioCode ###
453
+ .vscode/*
454
+ !.vscode/settings.json
455
+ !.vscode/tasks.json
456
+ !.vscode/launch.json
457
+ !.vscode/extensions.json
458
+ !.vscode/*.code-snippets
459
+
460
+ # Local History for Visual Studio Code
461
+ .history/
462
+
463
+ # Built Visual Studio Code Extensions
464
+ *.vsix
465
+
466
+ ### VisualStudioCode Patch ###
467
+ # Ignore all local history of files
468
+ .history
469
+ .ionide
470
+
471
+ ### Windows ###
472
+ # Windows thumbnail cache files
473
+ Thumbs.db
474
+ Thumbs.db:encryptable
475
+ ehthumbs.db
476
+ ehthumbs_vista.db
477
+
478
+ # Dump file
479
+ *.stackdump
480
+
481
+ # Folder config file
482
+ [Dd]esktop.ini
483
+
484
+ # Recycle Bin used on file shares
485
+ $RECYCLE.BIN/
486
+
487
+ # Windows Installer files
488
+ *.cab
489
+ *.msi
490
+ *.msix
491
+ *.msm
492
+ *.msp
493
+
494
+ # Windows shortcuts
495
+ *.lnk
496
+
497
+ # End of https://www.toptal.com/developers/gitignore/api/windows,python,pycharm+iml,pycharm+all,pycharm,macos,linux,visualstudiocode
498
+
499
+ # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
@@ -0,0 +1,3 @@
1
+ # 2025.08.06
2
+
3
+ * Relax package requirements to be compatible with litellm
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: jims-backoffice
3
+ Version: 0.1.0.dev3
4
+ Summary: Add your description here
5
+ Author-email: Andrey Tatarinov <a@tatarinov.co>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: asyncpg>=0.31.0
8
+ Requires-Dist: fastapi>=0.115.0
9
+ Requires-Dist: fastui>=0.7.0
10
+ Requires-Dist: jims-core
11
+ Requires-Dist: psycopg2-binary>=2.9.10
12
+ Requires-Dist: pydantic-settings>=2.10.1
13
+ Requires-Dist: pydantic==2.9.2
14
+ Requires-Dist: python-multipart>=0.0.18
15
+ Requires-Dist: sqlalchemy>=2.0.41
16
+ Requires-Dist: uvicorn>=0.29.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # JIMS UI
20
+
21
+ Simple FastUI viewer of JIMS threads
@@ -0,0 +1,3 @@
1
+ # JIMS UI
2
+
3
+ Simple FastUI viewer of JIMS threads
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "jims-backoffice"
3
+ dynamic = ["version"]
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [
8
+ { name = "Andrey Tatarinov", email = "a@tatarinov.co" },
9
+ ]
10
+
11
+ dependencies = [
12
+ "fastapi>=0.115.0",
13
+ "fastui>=0.7.0",
14
+ "pydantic-settings>=2.10.1",
15
+ "sqlalchemy>=2.0.41",
16
+ "psycopg2-binary>=2.9.10",
17
+ "uvicorn>=0.29.0",
18
+ "pydantic==2.9.2",
19
+ "jims-core",
20
+ "python-multipart>=0.0.18",
21
+ "asyncpg>=0.31.0",
22
+ ]
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "mypy>=1.19.0",
27
+ "pytest>=8.4.1",
28
+ "ruff>=0.14.10",
29
+ "types-pyyaml>=6.0.12.20250822",
30
+ ]
31
+
32
+ [project.scripts]
33
+ jims-backoffice = "jims_backoffice.app:main"
34
+
35
+ [build-system]
36
+ requires = ["hatchling", "uv-dynamic-versioning"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.version]
40
+ source = "uv-dynamic-versioning"
41
+
42
+ [tool.uv-dynamic-versioning]
43
+ enable = true
44
+ vcs = "git"
45
+ pattern = "default"
46
+
47
+ [tool.uv-workspace-codegen]
48
+ generate = true
49
+ template_type = ["lib", "publish"]
50
+ generate_standard_pytest_step = false
51
+ typechecker = "mypy"
@@ -0,0 +1,41 @@
1
+ from fastapi import FastAPI, status
2
+ from fastapi.responses import HTMLResponse
3
+ from fastui import prebuilt_html
4
+
5
+
6
+ def set_routes(
7
+ app: FastAPI,
8
+ ) -> FastAPI:
9
+ TITLE = "Backoffice"
10
+
11
+ @app.get("/healthz", status_code=status.HTTP_200_OK)
12
+ def healthcheck() -> dict[str, str]:
13
+ return {"status": "ok"}
14
+
15
+ @app.get("/{path:path}")
16
+ async def html_landing():
17
+ """Simple HTML page which serves the React app, comes last as it matches all paths."""
18
+ return HTMLResponse(prebuilt_html(title=TITLE))
19
+
20
+ return app
21
+
22
+
23
+ def get_application() -> FastAPI:
24
+ from jims_backoffice.main_app import application
25
+ from jims_backoffice.routes import event, home # noqa
26
+
27
+ set_routes(application)
28
+ return application
29
+
30
+
31
+ app = get_application()
32
+
33
+
34
+ def main() -> None:
35
+ import uvicorn
36
+
37
+ uvicorn.run(app, host="0.0.0.0", port=8000)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
@@ -0,0 +1,21 @@
1
+ from typing import Annotated
2
+
3
+ from fastui.forms import Textarea
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class ThreadSearchForm(BaseModel):
8
+ thread_id: str | None = Field(None, description="Thread ID")
9
+
10
+
11
+ class EventSearchForm(BaseModel):
12
+ thread_id: str | None = Field(None, description="Thread ID")
13
+ event_id: str | None = Field(None, description="Event ID")
14
+ event_type: str | None = Field(None, description="Event Type")
15
+ event_domain: str | None = Field(None, description="Event Domain")
16
+ event_name: str | None = Field(None, description="Event Name")
17
+
18
+
19
+ class FeedbackForm(BaseModel):
20
+ value: int = Field(description="Feedback Value", ge=1, le=5)
21
+ comment: Annotated[str, Textarea(rows=5)] | None = Field(None, description="Feedback Comment")
@@ -0,0 +1,3 @@
1
+ from fastapi import FastAPI
2
+
3
+ application = FastAPI(docs_url=None, redoc_url=None)
@@ -0,0 +1,233 @@
1
+ import datetime
2
+ from typing import Annotated
3
+ from uuid import UUID
4
+
5
+ import sqlalchemy as sa
6
+ from fastapi import Depends, HTTPException
7
+ from fastui import AnyComponent, FastUI
8
+ from fastui import components as c
9
+ from fastui.components.display import DisplayLookup, DisplayMode
10
+ from fastui.events import GoToEvent, PageEvent
11
+ from fastui.forms import fastui_form
12
+ from jims_backoffice.forms import EventSearchForm, FeedbackForm
13
+ from jims_backoffice.main_app import application
14
+ from jims_backoffice.utils import (
15
+ PaginationModel,
16
+ common_page,
17
+ create_actions_buttons,
18
+ create_table_and_search,
19
+ generate_fields,
20
+ get_async_sessionmaker,
21
+ get_pagination,
22
+ get_sessionmaker,
23
+ get_thread_links,
24
+ )
25
+ from jims_core.db import ThreadEventDB
26
+ from jims_core.thread.thread_controller import ThreadController
27
+ from jims_core.util import uuid7
28
+ from pydantic import BaseModel
29
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
30
+ from sqlalchemy.orm import Session, sessionmaker
31
+
32
+
33
+ @generate_fields
34
+ def generate_events() -> dict[str, DisplayLookup]:
35
+ return {
36
+ "thread_id": DisplayLookup(field="thread_id", on_click=GoToEvent(url="/thread/{thread_id}")),
37
+ "event_id": DisplayLookup(field="event_id", on_click=GoToEvent(url="/thread/{thread_id}/event/{event_id}")),
38
+ "created_at": DisplayLookup(field="created_at"),
39
+ "event_type": DisplayLookup(field="event_type"),
40
+ "event_domain": DisplayLookup(field="event_domain"),
41
+ "event_name": DisplayLookup(field="event_name"),
42
+ "event_data": DisplayLookup(field="event_data", mode=DisplayMode.json),
43
+ }
44
+
45
+
46
+ def get_event_all_events(
47
+ limit: int,
48
+ offset: int,
49
+ thread_id: str | None = None,
50
+ event_id: str | None = None,
51
+ event_type: str | None = None,
52
+ event_domain: str | None = None,
53
+ event_name: str | None = None,
54
+ sessionmaker: sessionmaker[Session] = get_sessionmaker(),
55
+ ) -> tuple[int, list[ThreadEventDB]]:
56
+ stmt = sa.select(ThreadEventDB)
57
+ if thread_id:
58
+ try:
59
+ tmp = UUID(thread_id)
60
+ stmt = stmt.where(ThreadEventDB.thread_id == tmp)
61
+ except ValueError:
62
+ return 0, []
63
+ if event_id:
64
+ try:
65
+ tmp = UUID(event_id)
66
+ stmt = stmt.where(ThreadEventDB.event_id == tmp)
67
+ except ValueError:
68
+ return 0, []
69
+ if event_type:
70
+ stmt = stmt.where(ThreadEventDB.event_type.like(f"%{event_type}%"))
71
+ if event_domain:
72
+ stmt = stmt.where(ThreadEventDB.event_domain == event_domain)
73
+ if event_name:
74
+ stmt = stmt.where(ThreadEventDB.event_name == event_name)
75
+ stmt = stmt.order_by(ThreadEventDB.created_at.desc())
76
+ count_stmt = sa.select(sa.func.count("*")).select_from(stmt.subquery())
77
+ stmt = stmt.limit(limit).offset(offset)
78
+ with sessionmaker() as session:
79
+ count_result = session.execute(count_stmt).scalar_one()
80
+ result = session.execute(stmt).scalars().all()
81
+ return count_result, list(result)
82
+
83
+
84
+ def get_event_by_thread_id_and_event_id(
85
+ thread_id: UUID, event_id: UUID, sessionmaker: sessionmaker[Session] = get_sessionmaker()
86
+ ) -> ThreadEventDB | None:
87
+ stmt = sa.select(ThreadEventDB).where(ThreadEventDB.thread_id == thread_id, ThreadEventDB.event_id == event_id)
88
+ with sessionmaker() as session:
89
+ res = session.execute(stmt).scalar_one_or_none()
90
+ return res
91
+
92
+
93
+ async def async_is_event_exists(
94
+ thread_id: UUID,
95
+ event_id: UUID,
96
+ sessionmaker: async_sessionmaker[AsyncSession] = get_async_sessionmaker(),
97
+ ) -> bool:
98
+ stmt = sa.select(sa.exists().where(ThreadEventDB.thread_id == thread_id, ThreadEventDB.event_id == event_id))
99
+ async with sessionmaker() as session:
100
+ res = (await session.execute(stmt)).scalar_one()
101
+ return res
102
+
103
+
104
+ class EventModel(BaseModel):
105
+ thread_id: UUID
106
+ event_id: UUID
107
+ created_at: datetime.datetime
108
+ event_type: str
109
+ event_domain: str | None = None
110
+ event_name: str | None = None
111
+ event_data: dict | None = None
112
+
113
+
114
+ @application.get("/api/thread/{thread_id}/event", response_model=FastUI, response_model_exclude_none=True)
115
+ def get_event_page(
116
+ thread_id: str,
117
+ event_id: str | None = None,
118
+ event_type: str | None = None,
119
+ event_domain: str | None = None,
120
+ event_name: str | None = None,
121
+ pagination: PaginationModel = Depends(get_pagination),
122
+ ) -> list[AnyComponent]:
123
+ total, events = get_event_all_events(
124
+ event_id=event_id,
125
+ event_type=event_type,
126
+ event_domain=event_domain,
127
+ event_name=event_name,
128
+ thread_id=thread_id,
129
+ limit=pagination.items_per_page,
130
+ offset=pagination.offset,
131
+ )
132
+ event_table = create_table_and_search(
133
+ search_form=EventSearchForm,
134
+ table=c.Table(
135
+ data_model=EventModel,
136
+ data=[
137
+ EventModel(
138
+ thread_id=event.thread_id,
139
+ event_id=event.event_id,
140
+ created_at=event.created_at,
141
+ event_type=event.event_type,
142
+ event_domain=event.event_domain,
143
+ event_name=event.event_name,
144
+ event_data=event.event_data,
145
+ )
146
+ for event in events
147
+ ],
148
+ columns=generate_events(),
149
+ ),
150
+ page_event_name="search-event",
151
+ page=pagination.page,
152
+ items_per_page=pagination.items_per_page,
153
+ total=total,
154
+ )
155
+ event_page = c.Div(
156
+ components=[
157
+ event_table,
158
+ ]
159
+ )
160
+ return common_page(
161
+ get_thread_links(thread_id),
162
+ "Events",
163
+ event_page,
164
+ )
165
+
166
+
167
+ @application.get("/api/thread/{thread_id}/event/{event_id}", response_model=FastUI, response_model_exclude_none=True)
168
+ def get_event_detail_page(
169
+ thread_id: UUID,
170
+ event_id: UUID,
171
+ ) -> list[AnyComponent]:
172
+ event = get_event_by_thread_id_and_event_id(thread_id, event_id)
173
+ if event is None:
174
+ raise HTTPException(status_code=404, detail="Event not found")
175
+ event_model = EventModel(
176
+ thread_id=event.thread_id,
177
+ event_id=event.event_id,
178
+ created_at=event.created_at,
179
+ event_type=event.event_type,
180
+ event_domain=event.event_domain,
181
+ event_name=event.event_name,
182
+ event_data=event.event_data,
183
+ )
184
+ buttons = create_actions_buttons(
185
+ c.Button(text="Add Feedback", on_click=PageEvent(name="add-feedback-modal")),
186
+ )
187
+ event_page = c.Div(
188
+ components=[
189
+ buttons,
190
+ c.Details(data=event_model, fields=generate_events()), # type: ignore
191
+ c.Modal(
192
+ title="Add Feedback",
193
+ body=[
194
+ c.ModelForm(
195
+ model=FeedbackForm,
196
+ submit_url=f"/api/thread/{thread_id}/event/{event_id}/add-feedback",
197
+ )
198
+ ],
199
+ footer=[c.Button(text="Close", on_click=PageEvent(name="add-feedback-modal", clear=True))],
200
+ open_trigger=PageEvent(name="add-feedback-modal", clear=True),
201
+ ),
202
+ ] # type: ignore
203
+ )
204
+ return common_page(
205
+ get_thread_links(thread_id),
206
+ "Event",
207
+ event_page,
208
+ )
209
+
210
+
211
+ @application.post("/api/thread/{thread_id}/event/{event_id}/add-feedback")
212
+ async def add_feedback(
213
+ thread_id: UUID,
214
+ event_id: UUID,
215
+ feedback: Annotated[FeedbackForm, fastui_form(FeedbackForm)],
216
+ ) -> list[AnyComponent]:
217
+ thread_controller = await ThreadController.from_thread_id(get_async_sessionmaker(), thread_id)
218
+ if thread_controller is None:
219
+ raise HTTPException(status_code=404, detail="Thread not found")
220
+
221
+ if not await async_is_event_exists(thread_id, event_id):
222
+ raise HTTPException(status_code=404, detail="Event not found")
223
+
224
+ data = {
225
+ **feedback.model_dump(),
226
+ "event_id": str(event_id),
227
+ }
228
+ await thread_controller.store_event_dict(
229
+ event_id=uuid7(),
230
+ event_type="jims.backoffice.feedback",
231
+ event_data=data,
232
+ )
233
+ return [c.Text(text="Operation was a success. Refresh the page to see result!")]
@@ -0,0 +1,142 @@
1
+ import datetime
2
+ from typing import cast
3
+ from uuid import UUID
4
+
5
+ import sqlalchemy as sa
6
+ from fastapi import Depends, HTTPException
7
+ from fastui import AnyComponent, FastUI
8
+ from fastui import components as c
9
+ from fastui.components.display import DisplayLookup, DisplayMode
10
+ from fastui.events import GoToEvent
11
+ from jims_backoffice.forms import ThreadSearchForm
12
+ from jims_backoffice.main_app import application
13
+ from jims_backoffice.utils import (
14
+ FieldsType,
15
+ PaginationModel,
16
+ common_page,
17
+ create_table_and_search,
18
+ generate_fields,
19
+ get_pagination,
20
+ get_sessionmaker,
21
+ get_thread_links,
22
+ )
23
+ from jims_core.db import ThreadDB
24
+ from pydantic import BaseModel
25
+ from sqlalchemy.orm import Session, sessionmaker
26
+
27
+
28
+ @generate_fields
29
+ def generate_threads() -> dict[str, DisplayLookup]:
30
+ return {
31
+ "thread_id": DisplayLookup(field="thread_id", on_click=GoToEvent(url="/thread/{thread_id}")),
32
+ "created_at": DisplayLookup(field="created_at"),
33
+ "thread_config": DisplayLookup(field="thread_config", mode=DisplayMode.json),
34
+ }
35
+
36
+
37
+ def get_all_thread(
38
+ limit: int, offset: int, thread_id: str | None = None, sessionmaker: sessionmaker[Session] = get_sessionmaker()
39
+ ) -> tuple[int, list[ThreadDB]]:
40
+ stmt = sa.select(ThreadDB)
41
+ if thread_id:
42
+ try:
43
+ tmp = UUID(thread_id)
44
+ stmt = stmt.where(ThreadDB.thread_id == tmp)
45
+ except ValueError:
46
+ return 0, []
47
+ stmt = stmt.order_by(ThreadDB.created_at.desc())
48
+ count_stmt = sa.select(sa.func.count("*")).select_from(stmt.subquery())
49
+ stmt = stmt.limit(limit).offset(offset)
50
+ with sessionmaker() as session:
51
+ count_result = session.execute(count_stmt).scalar_one()
52
+ result = session.execute(stmt).scalars().all()
53
+ return count_result, list(result)
54
+
55
+
56
+ def get_thread_by_id(thread_id: str, sessionmaker: sessionmaker[Session] = get_sessionmaker()) -> ThreadDB | None:
57
+ try:
58
+ tmp = UUID(thread_id)
59
+ except ValueError:
60
+ return None
61
+
62
+ stmt = sa.select(ThreadDB).where(ThreadDB.thread_id == tmp)
63
+ with sessionmaker() as session:
64
+ res = session.execute(stmt).scalar_one_or_none()
65
+ return res
66
+
67
+
68
+ class ThreadModel(BaseModel):
69
+ thread_id: UUID
70
+ created_at: datetime.datetime
71
+ thread_config: dict
72
+
73
+
74
+ @application.get("/api/", response_model=FastUI, response_model_exclude_none=True)
75
+ def get_thread_table_page(
76
+ thread_id: str | None = None,
77
+ pagination: PaginationModel = Depends(get_pagination),
78
+ ) -> list[AnyComponent]:
79
+ total, threads = get_all_thread(
80
+ thread_id=thread_id,
81
+ limit=pagination.items_per_page,
82
+ offset=pagination.offset,
83
+ )
84
+ thread_table = create_table_and_search(
85
+ search_form=ThreadSearchForm,
86
+ table=c.Table(
87
+ data_model=ThreadModel,
88
+ data=[
89
+ ThreadModel(
90
+ thread_id=thread.thread_id,
91
+ created_at=thread.created_at
92
+ if isinstance(thread.created_at, datetime.datetime)
93
+ else datetime.datetime.fromisoformat(str(thread.created_at)),
94
+ thread_config=thread.thread_config,
95
+ )
96
+ for thread in threads
97
+ ],
98
+ columns=generate_threads(),
99
+ ),
100
+ page_event_name="search-thread",
101
+ page=pagination.page,
102
+ items_per_page=pagination.items_per_page,
103
+ total=total,
104
+ )
105
+
106
+ thread_page = c.Div(
107
+ components=[
108
+ thread_table,
109
+ ]
110
+ )
111
+ return common_page(
112
+ get_thread_links(),
113
+ "Threads",
114
+ thread_page,
115
+ )
116
+
117
+
118
+ @application.get("/api/thread/{thread_id}", response_model=FastUI, response_model_exclude_none=True)
119
+ def get_thread_page(
120
+ thread_id: str,
121
+ ) -> list[AnyComponent]:
122
+ thread = get_thread_by_id(thread_id)
123
+ if thread is None:
124
+ raise HTTPException(status_code=404, detail="Thread not found")
125
+ thread_model = ThreadModel(
126
+ thread_id=thread.thread_id,
127
+ created_at=thread.created_at
128
+ if isinstance(thread.created_at, datetime.datetime)
129
+ else datetime.datetime.fromisoformat(str(thread.created_at)),
130
+ thread_config=thread.thread_config,
131
+ )
132
+ data = c.Details(data=thread_model, fields=cast(FieldsType, generate_threads()))
133
+ thread_page = c.Div(
134
+ components=[
135
+ data,
136
+ ]
137
+ )
138
+ return common_page(
139
+ get_thread_links(thread_id),
140
+ "Thread",
141
+ thread_page,
142
+ )
@@ -0,0 +1,19 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class DatabaseSettings(BaseSettings):
5
+ model_config = SettingsConfigDict(env_prefix="JIMS_", extra="ignore", env_file=".env")
6
+
7
+ db_conn_uri: str
8
+
9
+ @property
10
+ def dsn(self) -> str:
11
+ return self.db_conn_uri
12
+
13
+ @property
14
+ def async_dsn(self) -> str:
15
+ if self.dsn.startswith("postgresql://"):
16
+ return self.dsn.replace("postgresql://", "postgresql+asyncpg://")
17
+ elif self.dsn.startswith("sqlite://"):
18
+ return self.dsn.replace("sqlite://", "sqlite+aiosqlite://")
19
+ return self.dsn
@@ -0,0 +1,151 @@
1
+ import functools
2
+ from functools import cache
3
+ from typing import Callable, ParamSpec, Type, TypeVar
4
+ from uuid import UUID
5
+
6
+ from fastui import AnyComponent
7
+ from fastui import components as c
8
+ from fastui.components.display import Display, DisplayLookup
9
+ from fastui.events import GoToEvent, PageEvent
10
+ from pydantic import BaseModel
11
+ from sqlalchemy import Engine, create_engine
12
+ from sqlalchemy.ext.asyncio import (
13
+ AsyncEngine,
14
+ AsyncSession,
15
+ async_sessionmaker,
16
+ create_async_engine,
17
+ )
18
+ from sqlalchemy.orm import Session, sessionmaker
19
+
20
+ from jims_backoffice.settings import DatabaseSettings
21
+
22
+ T = TypeVar("T")
23
+
24
+
25
+ @cache
26
+ def get_engine() -> Engine:
27
+ db_config = DatabaseSettings() # type: ignore
28
+ engine = create_engine(db_config.dsn, pool_size=10)
29
+ return engine
30
+
31
+
32
+ @cache
33
+ def get_async_engine() -> AsyncEngine:
34
+ db_config = DatabaseSettings() # type: ignore
35
+ engine = create_async_engine(db_config.async_dsn, pool_size=100)
36
+ return engine
37
+
38
+
39
+ @cache
40
+ def get_sessionmaker() -> sessionmaker[Session]:
41
+ engine = get_engine()
42
+ return sessionmaker(bind=engine, autoflush=True, expire_on_commit=False)
43
+
44
+
45
+ @cache
46
+ def get_async_sessionmaker() -> async_sessionmaker[AsyncSession]:
47
+ engine = get_async_engine()
48
+ return async_sessionmaker(bind=engine, autoflush=True, expire_on_commit=False)
49
+
50
+
51
+ class PaginationModel(BaseModel):
52
+ page: int
53
+ items_per_page: int
54
+ offset: int
55
+
56
+
57
+ def get_pagination(page: int = 1, items_per_page: int = 20) -> PaginationModel:
58
+ if page <= 0:
59
+ page = 1
60
+ offset = (page - 1) * items_per_page
61
+ return PaginationModel(page=page, items_per_page=items_per_page, offset=offset)
62
+
63
+
64
+ TITLE = "Backoffice"
65
+
66
+
67
+ def common_page(links: list | None = None, title: str | None = None, *components: AnyComponent) -> list[AnyComponent]:
68
+ if links is None:
69
+ links = []
70
+ return [
71
+ c.PageTitle(text=f"{TITLE} — {title}" if title else f"{TITLE}"),
72
+ c.Navbar(
73
+ title=TITLE,
74
+ title_event=GoToEvent(url="/"),
75
+ start_links=links,
76
+ ),
77
+ c.Page(
78
+ components=[
79
+ *((c.Heading(text=title),) if title else ()),
80
+ *components,
81
+ ],
82
+ ),
83
+ c.Footer(
84
+ extra_text=TITLE,
85
+ links=[],
86
+ ),
87
+ ]
88
+
89
+
90
+ def get_thread_links(thread_id: UUID | str | None = None) -> list[c.Link]:
91
+ if thread_id is None:
92
+ return []
93
+ return [
94
+ c.Link(
95
+ components=[c.Text(text=f"Thread: {thread_id} >")],
96
+ on_click=GoToEvent(url=f"/thread/{thread_id}"),
97
+ active="startswith:/",
98
+ ),
99
+ c.Link(
100
+ components=[c.Text(text="Events")],
101
+ on_click=GoToEvent(url=f"/thread/{thread_id}/event"),
102
+ active="startswith:/event",
103
+ ),
104
+ ]
105
+
106
+
107
+ P = ParamSpec("P")
108
+
109
+ FieldsType = list[DisplayLookup | Display] | None
110
+
111
+
112
+ def generate_fields(func: Callable[P, dict[str, DisplayLookup]]):
113
+ @functools.wraps(func)
114
+ def wrapper(exclude: set[str] | None = None, *args: P.args, **kwargs: P.kwargs) -> list[DisplayLookup]:
115
+ if exclude is None:
116
+ exclude = set()
117
+ table = func(*args, **kwargs)
118
+ return [value for key, value in table.items() if key not in exclude]
119
+
120
+ return wrapper
121
+
122
+
123
+ def create_table_and_search(
124
+ *, table: c.Table, search_form: Type[BaseModel], page_event_name: str, page: int, items_per_page: int, total: int
125
+ ) -> c.Div:
126
+ return c.Div(
127
+ components=[
128
+ c.Div(
129
+ components=[
130
+ c.ModelForm(
131
+ model=search_form,
132
+ submit_url=".",
133
+ method="GOTO",
134
+ submit_on_change=True,
135
+ display_mode="inline",
136
+ submit_trigger=PageEvent(name=page_event_name),
137
+ ),
138
+ table,
139
+ ],
140
+ class_name="mt-1",
141
+ ),
142
+ c.Pagination(page=page, page_size=items_per_page, total=total),
143
+ ],
144
+ )
145
+
146
+
147
+ def create_actions_buttons(*buttons: c.Button | None) -> c.Div:
148
+ return c.Div(
149
+ components=[c.Div(components=[button], class_name="mt-1") for button in buttons if button is not None],
150
+ class_name="col-md-6 mb-2",
151
+ )