BI-analytics-admin 0.1.0__py3-none-any.whl

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,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: BI-analytics-admin
3
+ Version: 0.1.0
4
+ Summary: Python project for datascience, web, and MSSQL integration
5
+ Author-email: bluemoondom <pillerova@mojeit.eu>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: python-dotenv>=1.0.0
13
+ Requires-Dist: numpy>=1.24.0
14
+ Requires-Dist: pandas>=2.0.0
15
+ Requires-Dist: scipy>=1.10.0
16
+ Requires-Dist: scikit-learn>=1.3.0
17
+ Requires-Dist: matplotlib>=3.7.0
18
+ Requires-Dist: seaborn>=0.12.0
19
+ Requires-Dist: jupyter>=1.0.0
20
+ Requires-Dist: notebook>=6.5.0
21
+ Requires-Dist: fastapi>=0.95.0
22
+ Requires-Dist: uvicorn>=0.21.0
23
+ Requires-Dist: jinja2>=3.1.0
24
+ Requires-Dist: requests>=2.28.0
25
+ Requires-Dist: pyodbc>=4.0.0
26
+ Requires-Dist: sqlalchemy>=2.0.0
27
+ Requires-Dist: pymssql>=2.2.0
28
+ Requires-Dist: pymysql>=1.0.2
29
+ Requires-Dist: psycopg2-binary>=2.9.0
30
+ Requires-Dist: openpyxl>=3.1.0
31
+ Requires-Dist: python-dateutil>=2.8.0
32
+ Requires-Dist: tqdm>=4.65.0
33
+ Requires-Dist: cryptography>=41.0.0
34
+ Dynamic: license-file
35
+
36
+ # Business Intelligence Analytics Admin — Features
37
+
38
+ ## Overview
39
+
40
+ Analytics Admin is a multi-user business intelligence platform that allows organizations to connect to their databases, model data views, and build interactive dashboards. Each user operates within an isolated workspace, with dedicated connectors, views, and dashboards.
41
+
42
+ ---
43
+
44
+ ## Multi-User Architecture
45
+
46
+ - Multi-user environment with separated connectors, views, and dashboards per user
47
+ - Account registration and authentication handled exclusively through email
48
+ - Multi-language interface support
49
+
50
+ ---
51
+
52
+ ## Database Connectors
53
+
54
+ - Supported database engines: Microsoft SQL Server, MySQL, MariaDB, PostgreSQL, and SQLite
55
+ - Allowed IP addresses configurable independently for each connector's API access
56
+ - API authentication via bearer key, with a configurable custom header name
57
+ - API tenant identifier for connector-level access control
58
+ - Configurable maximum request rate (requests per minute)
59
+
60
+ ---
61
+
62
+ ## View Modeling
63
+
64
+ The view modeling layer allows users to define reusable data structures on top of source tables and views.
65
+
66
+ - Modeling based on tables and views, with support for subviews
67
+ - Selection of the source connector for each view
68
+ - One-click exposure of a view as an API endpoint
69
+ - API access supports GET requests in both tabular and tree (hierarchical) structures, as well as PUT requests
70
+ - Downloadable sample `.bat` script demonstrating API usage
71
+ - Renaming of tables and columns to custom aliases directly by clicking on the column header
72
+ - Color-coded visual indication when a subview is added to the model
73
+ - Column selection for inclusion in a view via checkboxes
74
+ - Visual display of join conditions between tables
75
+ - Support for adding custom columns and custom conditions, with built-in protection against SQL injection
76
+ - Enhanced graphical interface for building WHERE conditions
77
+ - Support for ORDER BY and GROUP BY clauses
78
+ - Automatic SQL query generation with syntax highlighting
79
+ - Live data preview
80
+
81
+ ---
82
+
83
+ ## Dashboards
84
+
85
+ - Tabular display of data sourced from a view
86
+ - Automatic removal of NULL values from displayed results
87
+ - Conditional formatting of numeric values: positive values shown in green, negative values shown in red
88
+ - Support for numeric and date/time formatting
89
+ - Configurable row limits
90
+ - Automatic detection of data types to generate appropriate filtering conditions for a view
91
+
92
+ ### Pivot Tables
93
+
94
+ - Creation of pivot (cross-tabulated) tables
95
+ - Column hiding and reordering
96
+ - Configuration of pivot dimensions and aggregation functions: Sum, Count, Average, Maximum, and Minimum
97
+ - Display of columns that are neither assigned as dimensions nor as metrics
98
+
99
+ ### Charts
100
+
101
+ - Chart creation from a library of 17 predefined chart types
102
+ - Configuration of X and Y axes, with support for additional series
103
+ - Each series can be independently defined as a column (bar) or a line
104
+ - Further breakdown of a series by an additional dimension, based on a selected column
105
+ - Ability to show or hide individual values in the chart legend
106
+ - Toggle for displaying or hiding gridlines
107
+ - Configuration of chart dimensions and layout, including the number of charts displayed per row
108
+
109
+ ---
110
+
111
+ ## Security
112
+
113
+ - Transport security via SSL, supporting both PEM and PFX certificate formats
114
+ - Globally configurable trusted hosts (allowed IP addresses)
115
+ - CORS, Content Security Policy (CSP), and HSTS enforcement
116
+ - Request and access logging
117
+ - JWT secret used for verification of web tokens
118
+ - API bearer key and tenant identifier configured individually for each user and each connector
119
+ - Trusted host IP addresses configurable independently for each connector
120
+ - Configurable maximum request rate (requests per minute)
121
+ - Registration and authentication via email, with session validity limited by a defined time period
122
+ - Prevention of duplicate concurrent sessions
123
+
124
+ ---
125
+
126
+ ## Requirements and Installation
127
+
128
+ The application is started with the following command:
129
+
130
+ ```
131
+ python -m src.web.server
132
+ ```
133
+
134
+ ### Dependencies
135
+
136
+ **Core**
137
+ - python-dotenv >= 1.0.0
138
+
139
+ **Data Science**
140
+ - numpy >= 1.24.0
141
+ - pandas >= 2.0.0
142
+ - scipy >= 1.10.0
143
+ - scikit-learn >= 1.3.0
144
+ - matplotlib >= 3.7.0
145
+ - seaborn >= 0.12.0
146
+ - jupyter >= 1.0.0
147
+ - notebook >= 6.5.0
148
+
149
+ **Web**
150
+ - fastapi >= 0.95.0
151
+ - uvicorn >= 0.21.0
152
+ - jinja2 >= 3.1.0
153
+ - requests >= 2.28.0
154
+
155
+ **Database**
156
+ - pyodbc >= 4.0.0 (MSSQL / SQL Server)
157
+ - sqlalchemy >= 2.0.0
158
+ - pymssql >= 2.2.0
159
+ - pymysql >= 1.0.2 (MySQL / MariaDB connector)
160
+ - psycopg2-binary >= 2.9.0 (PostgreSQL connector)
161
+ - sqlite3 (SQLite) is included in the Python standard library and requires no separate package
162
+
163
+ **Office / Export**
164
+ - openpyxl >= 3.1.0
165
+
166
+ **Utilities**
167
+ - python-dateutil >= 2.8.0
168
+ - tqdm >= 4.65.0
169
+ - cryptography >= 41.0.0 (PFX/PKCS12 extraction for HTTPS)
170
+
171
+ ---
172
+
173
+ ## Screenshots
174
+
175
+ ![Dashboard view](screen1.jpg)
176
+
177
+ ![Dashboard chart configuration](screen2.jpg)
178
+
179
+ ![View modeling canvas](screen3.jpg)
180
+
181
+ ![Query conditions and generated SQL](screen4.jpg)
182
+
183
+ ![Connector management](screen5.jpg)
184
+
185
+ ---
@@ -0,0 +1,33 @@
1
+ bi_analytics_admin-0.1.0.dist-info/licenses/LICENSE,sha256=NnLb7U_0tvuiQxs8ZoONQ43zl8fmc0ErCzKemTVEEuo,4531
2
+ web/auth.py,sha256=wEtv_GENnXcrCLSkyBUpQiAbjV9Oigq2Ke4r5dln92w,8198
3
+ web/config.py,sha256=ZuOgBgIyZb1Jwr07crRNZxNFplTejOHUpZV6SJJzhfQ,6648
4
+ web/db.py,sha256=705N0szQOKwtGlMRhwijwOZL9Z4YdL01ACPUFAEmYHU,29695
5
+ web/main.py,sha256=JV1BwXk3vGOZWfZJ9QtHMJ0vvk24hXUmdxcODRYb7AM,3022
6
+ web/security.py,sha256=y7I8w2RSiZ3ia1rtlxSuI4DTvplH6SirMARdyA8chCg,3430
7
+ web/server.py,sha256=uJF8MISC5nbb_ui3pXbwOeyLkZKCwlnCx-d8HNK0DPU,1922
8
+ web/ssl_utils.py,sha256=ZkP4T8QLM47nrG5tPl40qGGv47vr25sibbNl6GWP8aI,2019
9
+ web/dialects/__init__.py,sha256=90eVNbONYJ3Tn5vx6zz6C6I2gv6jkch-ivb5iAVmXPE,1538
10
+ web/dialects/base.py,sha256=yZUjz8NYspdPoKYOBkXZeDjhr9We8YKalL2E5LuotVc,4990
11
+ web/dialects/mssql.py,sha256=8OgZcz369EofluuKdR1I-FsnpS0YuoCO_9KYa9YZ2HY,2998
12
+ web/dialects/mysql.py,sha256=IrKXH_SY-_Sz_ZKiGWa_kQ_l4yZdiuDW_zlrqYctKsQ,3732
13
+ web/dialects/postgresql.py,sha256=QzvKxfHX9MAQPLD17a1eVRv0QdTkrj4sMkWW9mqo7Zk,3728
14
+ web/dialects/sqlite.py,sha256=3w3WTimOIkkRNE_wdilmJVY5ltRp-mpTRXb37FCJLaQ,3926
15
+ web/models/dashboard.py,sha256=1DrYKD4wjFlvFIVa2sRx0a0tUguHgyAkg1gGOjK2igY,4744
16
+ web/models/user.py,sha256=gkjtR6DMAqChKaaiOBbniKmSRj8oVXjMbxS-AtX7mMk,1760
17
+ web/routers/auth.py,sha256=Wk0c66SiWmfapcrlltEEA7p8b4dzlxQPr5T6L96uyGE,5917
18
+ web/routers/connectors.py,sha256=UoqYRDRAgIj1nfgAQa0GJd7o084XmLopTUS4BNQzXqA,8962
19
+ web/routers/dashboards.py,sha256=BzVRfOmBJicaicJT7_8IuTtChEhZzkh0jYZrqEAP-Lk,6909
20
+ web/routers/public_api.py,sha256=Tf2athD-2il8T8XPgBeSOMl0VNZezW2qRMjB4W6L6Zc,16813
21
+ web/routers/views.py,sha256=TB8XHrRZ25jNxsJYsoIhBSaScWZgl01__liyvxGD-_I,6270
22
+ web/services/email_service.py,sha256=0SBoOYH5kUM0VX7d9Fp8jfZ7xTKf8wXPJAFnRRnWSZc,5065
23
+ web/services/export.py,sha256=RhjQMSBJ3xzB3_tB6fdeuBMz16SYphT2SHW2j5Bg7dU,916
24
+ web/services/migrations.py,sha256=cGohBDtntNUOzzyga41Hub6aV_OryyA-79fb-Cg4hSE,4093
25
+ web/services/query.py,sha256=GQGhGrte4fSEg00cyChquWRd52yss_Y4__oBKbZJdMw,3008
26
+ web/services/storage.py,sha256=8yY_HY8PhjSTYWjOg2mW5vQR3bFA-5FiRlaiLqKPL_4,32011
27
+ web/services/user_storage.py,sha256=pOkyNYtK1UHv2mcB0lHQZ1iCWw0Jo9sHPY1hWCNYOHM,38194
28
+ web/services/view_modeling.py,sha256=ODOYA27Ryb6lnjYyUiK1fL3ta101f1edyjdGzBxBQTg,70587
29
+ bi_analytics_admin-0.1.0.dist-info/METADATA,sha256=7pBdv48R5dQaVundpnCCzA80m4_4Rvjm7lIgurb4ab8,6091
30
+ bi_analytics_admin-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
31
+ bi_analytics_admin-0.1.0.dist-info/entry_points.txt,sha256=Ce2eHMuIJt4Y1YM9rznEL2ZnfZVnUL3GQbtcGjXq4vE,55
32
+ bi_analytics_admin-0.1.0.dist-info/top_level.txt,sha256=Te2Js_nwNom3AyuSoJHnQuEgXipUJ35SsySY2fzfNkI,4
33
+ bi_analytics_admin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ BI-analytics-admin = web.server:main
@@ -0,0 +1,134 @@
1
+ PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ Required Notice: Copyright Dominika Pillerová (https://myerp.cz) 2026
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree
10
+ to them as both strict obligations and conditions to all
11
+ your licenses.
12
+
13
+ ## Copyright License
14
+
15
+ The licensor grants you a copyright license for the software
16
+ to do everything you might do with the software that would
17
+ otherwise infringe the licensor's copyright in it for any
18
+ permitted purpose. However, you may only distribute the
19
+ software according to Distribution License and make changes
20
+ or new works based on the software according to Changes and
21
+ New Works License.
22
+
23
+ ## Distribution License
24
+
25
+ The licensor grants you an additional copyright license to
26
+ distribute copies of the software. Your license to distribute
27
+ covers distributing the software with changes and new works
28
+ permitted by Changes and New Works License.
29
+
30
+ ## Notices
31
+
32
+ You must ensure that anyone who gets a copy of any part of
33
+ the software from you also gets a copy of these terms or the
34
+ URL for them above, as well as copies of any plain-text lines
35
+ beginning with `Required Notice:` that the licensor provided
36
+ with the software. For example:
37
+
38
+ > Required Notice: Copyright MyERP.cz, Inc. (https://myerp.cz)
39
+
40
+ ## Changes and New Works License
41
+
42
+ The licensor grants you an additional copyright license to
43
+ make changes and new works based on the software for any
44
+ permitted purpose.
45
+
46
+ ## Patent License
47
+
48
+ The licensor grants you a patent license for the software
49
+ that covers patent claims the licensor can license, or
50
+ becomes able to license, that you would infringe by using
51
+ the software.
52
+
53
+ ## Noncommercial Purposes
54
+
55
+ Any noncommercial purpose is a permitted purpose.
56
+
57
+ ## Personal Uses
58
+
59
+ Personal use for research, experiment, and testing for the
60
+ benefit of public knowledge, personal study, private
61
+ entertainment, hobby projects, amateur pursuits, or religious
62
+ observance, without any anticipated commercial application,
63
+ is use for a permitted purpose.
64
+
65
+ ## Noncommercial Organizations
66
+
67
+ Use by any charitable organization, educational institution,
68
+ public research organization, public safety or health
69
+ organization, environmental protection organization, or
70
+ government institution is use for a permitted purpose
71
+ regardless of the source of funding or obligations resulting
72
+ from the funding.
73
+
74
+ ## Fair Use
75
+
76
+ You may have "fair use" rights for the software under the
77
+ law. These terms do not limit them.
78
+
79
+ ## No Other Rights
80
+
81
+ These terms do not allow you to sublicense or transfer any of
82
+ your licenses to anyone else, or prevent the licensor from
83
+ granting licenses to anyone else. These terms do not imply
84
+ any other licenses.
85
+
86
+ ## Patent Defense
87
+
88
+ If you make any written claim that the software infringes or
89
+ contributes to infringement of any patent, your patent
90
+ license for the software granted under these terms ends
91
+ immediately. If your company makes such a claim, your patent
92
+ license ends immediately for work on behalf of your company.
93
+
94
+ ## Violations
95
+
96
+ The first time you are notified in writing that you have
97
+ violated any of these terms, or done anything with the
98
+ software not covered by your licenses, your licenses can
99
+ nonetheless continue if you come into full compliance with
100
+ these terms, and take practical steps to correct past
101
+ violations, within 32 days of receiving notice. Otherwise,
102
+ all your licenses end immediately.
103
+
104
+ ## No Liability
105
+
106
+ ***As far as the law allows, the software comes as is,
107
+ without any warranty or condition, and the licensor will not
108
+ be liable to you for any damages arising out of these terms
109
+ or the use or nature of the software, under any kind of
110
+ legal claim.***
111
+
112
+ ## Definitions
113
+
114
+ The **licensor** is the individual or entity offering these
115
+ terms, and the **software** is the software the licensor
116
+ makes available under these terms.
117
+
118
+ **You** refers to the individual or entity agreeing to these
119
+ terms.
120
+
121
+ **Your company** is any legal entity, sole proprietorship, or
122
+ other kind of organization that you work for, plus all
123
+ organizations that have control over, are under the control
124
+ of, or are under common control with that organization.
125
+ **Control** means ownership of substantially all the assets
126
+ of an entity, or the power to direct its management and
127
+ policies by vote, contract, or otherwise. Control can be
128
+ direct or indirect.
129
+
130
+ **Your licenses** are all the licenses granted to you for the
131
+ software under these terms.
132
+
133
+ **Use** means anything you do with the software requiring one
134
+ of your licenses.
@@ -0,0 +1 @@
1
+ web
web/auth.py ADDED
@@ -0,0 +1,232 @@
1
+ """Authentication layer: magic links and JWT sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import secrets
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import Any
10
+
11
+ import jwt
12
+ from fastapi import HTTPException, Request, Response
13
+
14
+ from src.web.config import get_settings
15
+ from src.web.models.user import Connector, User, UserRole
16
+ from src.web.services.user_storage import UserStorage
17
+
18
+ ALGORITHM = "HS256"
19
+ MAGIC_LINK_EXPIRE_MINUTES = 15
20
+ SESSION_EXPIRE_HOURS = 24
21
+ COOKIE_NAME = "session"
22
+
23
+ # Magic link tokens are generated by secrets.token_urlsafe(32) which produces
24
+ # exactly 43 URL-safe base64 characters (letters, digits, '-' and '_').
25
+ # The regex enforces this format on every incoming token before it is used.
26
+ MAGIC_LINK_TOKEN_RE = re.compile(r"^[A-Za-z0-9\-_]{43}$")
27
+ MAGIC_LINK_TOKEN_LENGTH = 43
28
+
29
+
30
+ def _now() -> datetime:
31
+ return datetime.now(timezone.utc)
32
+
33
+
34
+ def _hash_token(token: str) -> str:
35
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
36
+
37
+
38
+ def _random_token(n: int = 32) -> str:
39
+ return secrets.token_urlsafe(n)
40
+
41
+
42
+ def create_magic_link(email: str, storage: UserStorage | None = None) -> tuple[str, str]:
43
+ """Create a single-use magic link token and return (token, url)."""
44
+ if storage is None:
45
+ storage = UserStorage()
46
+ user = storage.get_user_by_email(email)
47
+ if user is None:
48
+ raise HTTPException(status_code=403, detail="Neplatný email.")
49
+ if user.role != UserRole.NEW_ADMIN and not user.is_active:
50
+ # Pending new_admin can receive a magic link even while inactive.
51
+ raise HTTPException(status_code=403, detail="Účet není aktivní.")
52
+
53
+ token = _random_token()
54
+ token_hash = _hash_token(token)
55
+ expires_at = _now() + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)
56
+ storage.save_magic_link(token_hash, email, expires_at)
57
+
58
+ settings = get_settings()
59
+ base_url = settings.API_BASE_URL.rsplit("/", 1)[0] if "/" in settings.API_BASE_URL else settings.API_BASE_URL
60
+ url = f"{base_url}/auth/magic-link-redirect?token={token}"
61
+ return token, url
62
+
63
+
64
+ def register_user(email: str, storage: UserStorage | None = None) -> tuple[User, str, str]:
65
+ """Create a pending new_admin user and return (user, token, url).
66
+
67
+ The user is created inactive with role ``new_admin``. The first magic-link
68
+ click activates the account and promotes the role to ``admin``.
69
+ """
70
+ if storage is None:
71
+ storage = UserStorage()
72
+ if not get_settings().REGISTRATION_ENABLED:
73
+ raise HTTPException(status_code=403, detail="Registrace není povolena.")
74
+ existing = storage.get_user_by_email(email)
75
+ if existing is not None:
76
+ raise HTTPException(status_code=409, detail="Uživatel s tímto emailem již existuje.")
77
+
78
+ # New admin gets a copy of the default (.env) connector.
79
+ connector = _default_connector_from_settings()
80
+ connector.name = email
81
+ created_connector = storage.create_connector(connector)
82
+ user = storage.create_user(
83
+ email=email,
84
+ role=UserRole.NEW_ADMIN,
85
+ connector_id=created_connector.id,
86
+ )
87
+ token, url = create_magic_link(email, storage)
88
+ return user, token, url
89
+
90
+
91
+ def _default_connector_from_settings() -> Connector:
92
+ settings = get_settings()
93
+ return Connector(
94
+ name="Default (.env)",
95
+ db_host=settings.DB_HOST,
96
+ db_port=settings.DB_PORT,
97
+ db_name=settings.DB_NAME,
98
+ db_user=settings.DB_USER,
99
+ db_password=settings.DB_PASSWORD,
100
+ db_driver=settings.DB_DRIVER,
101
+ view_discovery_mode=settings.VIEW_DISCOVERY_MODE,
102
+ api_tenant=settings.API_TENANT,
103
+ api_key=settings.API_KEY or "",
104
+ api_key_header=settings.API_KEY_HEADER,
105
+ api_allowed_ips=settings.API_ALLOWED_IPS,
106
+ api_max_requests_per_minute=settings.API_MAX_REQUESTS_PER_MINUTE,
107
+ )
108
+
109
+
110
+ def _validate_magic_link_token(token: str) -> str:
111
+ """Reject any magic link token that does not match the expected format.
112
+
113
+ Only URL-safe base64 characters of the exact length produced by
114
+ ``secrets.token_urlsafe(32)`` are accepted. This prevents pathological
115
+ or malformed input from ever reaching the database lookup.
116
+ """
117
+ if not token or not MAGIC_LINK_TOKEN_RE.match(token):
118
+ raise HTTPException(status_code=400, detail="Neplatný nebo expirovaný odkaz.")
119
+ return token
120
+
121
+
122
+ def consume_magic_link(token: str, storage: UserStorage | None = None) -> str:
123
+ """Validate magic link token and create a session JWT.
124
+
125
+ If the user is a pending ``new_admin``, activate them and promote the role
126
+ to ``admin`` on first use.
127
+ """
128
+ token = _validate_magic_link_token(token)
129
+ if storage is None:
130
+ storage = UserStorage()
131
+ token_hash = _hash_token(token)
132
+ link = storage.use_magic_link(token_hash)
133
+ if link is None:
134
+ raise HTTPException(status_code=400, detail="Neplatný nebo expirovaný odkaz.")
135
+ user = storage.get_user_by_email(link["email"])
136
+ if user is None:
137
+ raise HTTPException(status_code=403, detail="Účet nebyl nalezen.")
138
+ if user.role == UserRole.NEW_ADMIN:
139
+ storage.activate_new_admin(user.id)
140
+ user = storage.get_user_by_id(user.id)
141
+ if user is None or not user.is_active:
142
+ raise HTTPException(status_code=403, detail="Účet nebyl nalezen nebo není aktivní.")
143
+ session_token = create_session_token(user)
144
+ storage.save_session(_hash_token(session_token), user.id)
145
+ return session_token
146
+
147
+
148
+ def create_session_token(user: User) -> str:
149
+ """Return signed JWT for the user."""
150
+ settings = get_settings()
151
+ now = _now()
152
+ payload = {
153
+ "sub": str(user.id),
154
+ "email": user.email,
155
+ "role": user.role.value,
156
+ "connector_id": user.connector_id,
157
+ "iat": now,
158
+ "exp": now + timedelta(hours=SESSION_EXPIRE_HOURS),
159
+ }
160
+ return jwt.encode(payload, settings.JWT_SECRET, algorithm=ALGORITHM)
161
+
162
+
163
+
164
+ def set_session_cookie(response: Response, token: str, *, secure: bool | None = None) -> None:
165
+ if secure is None:
166
+ settings = get_settings()
167
+ secure = not settings.LOCALHOST_MODE
168
+ response.set_cookie(
169
+ key=COOKIE_NAME,
170
+ value=token,
171
+ httponly=True,
172
+ secure=secure,
173
+ samesite="lax",
174
+ path="/",
175
+ max_age=int(timedelta(hours=SESSION_EXPIRE_HOURS).total_seconds()),
176
+ )
177
+
178
+
179
+ def clear_session_cookie(response: Response) -> None:
180
+ response.delete_cookie(key=COOKIE_NAME)
181
+
182
+
183
+ def _hash_token(token: str) -> str:
184
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
185
+
186
+
187
+ def _decode_token(token: str) -> dict[str, Any]:
188
+ try:
189
+ return jwt.decode(token, get_settings().JWT_SECRET, algorithms=[ALGORITHM])
190
+ except jwt.ExpiredSignatureError as exc:
191
+ raise HTTPException(status_code=401, detail="Session expired") from exc
192
+ except jwt.InvalidTokenError as exc:
193
+ raise HTTPException(status_code=401, detail="Invalid session") from exc
194
+
195
+
196
+ def get_current_user(request: Request) -> User:
197
+ token = request.cookies.get(COOKIE_NAME)
198
+ if not token:
199
+ raise HTTPException(status_code=401, detail="Not authenticated")
200
+ storage = UserStorage()
201
+ token_hash = _hash_token(token)
202
+ if not storage.is_session_valid(token_hash):
203
+ raise HTTPException(status_code=401, detail="Session revoked")
204
+ payload = _decode_token(token)
205
+ user_id = payload.get("sub")
206
+ user = storage.get_user_by_id(int(user_id)) if user_id else None
207
+ if user is None or not user.is_active:
208
+ raise HTTPException(status_code=401, detail="User not found or inactive")
209
+ return user
210
+
211
+
212
+ def get_current_user_optional(request: Request) -> User | None:
213
+ """Return the current user if authenticated, otherwise None."""
214
+ try:
215
+ return get_current_user(request)
216
+ except HTTPException:
217
+ return None
218
+
219
+
220
+ def get_current_admin(request: Request) -> User:
221
+ user = get_current_user(request)
222
+ if user.role != UserRole.ADMIN:
223
+ raise HTTPException(status_code=403, detail="Admin access required")
224
+ return user
225
+
226
+
227
+ def require_user(request: Request) -> User:
228
+ return get_current_user(request)
229
+
230
+
231
+ def require_admin(request: Request) -> User:
232
+ return get_current_admin(request)