owe 0.1.0__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.
- owe-0.1.0/LICENSE +21 -0
- owe-0.1.0/PKG-INFO +208 -0
- owe-0.1.0/README.md +176 -0
- owe-0.1.0/owe/__init__.py +20 -0
- owe-0.1.0/owe/app.py +204 -0
- owe-0.1.0/owe/config.py +24 -0
- owe-0.1.0/owe/database.py +175 -0
- owe-0.1.0/owe/owe.py +126 -0
- owe-0.1.0/owe/record.py +158 -0
- owe-0.1.0/owe/scripts/owe_dump.py +62 -0
- owe-0.1.0/owe/scripts/owe_users.py +136 -0
- owe-0.1.0/owe/static/index.html +106 -0
- owe-0.1.0/owe/static/main.js +375 -0
- owe-0.1.0/owe/telegram.py +122 -0
- owe-0.1.0/owe/user.py +38 -0
- owe-0.1.0/pyproject.toml +54 -0
owe-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 - 2026 Ruoyu Zhong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
owe-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: owe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bill splitter and tracker.
|
|
5
|
+
Keywords: bill tracking,bill splitting,bill tracker,bill splitter
|
|
6
|
+
Author: Ruoyu Zhong
|
|
7
|
+
Author-email: Ruoyu Zhong <zhongruoyu@outlook.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: Flask
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Dist: flask>=3.1.3
|
|
25
|
+
Requires-Dist: flask-cors>=6.0.2
|
|
26
|
+
Requires-Dist: gunicorn>=26.0.0
|
|
27
|
+
Requires-Dist: requests>=2.34.2
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Project-URL: Homepage, https://github.com/ZhongRuoyu/owe
|
|
30
|
+
Project-URL: Repository, https://github.com/ZhongRuoyu/owe.git
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Owe
|
|
34
|
+
|
|
35
|
+
Owe is a simple web application for splitting bills and tracking who owes whom
|
|
36
|
+
how much.
|
|
37
|
+
|
|
38
|
+
## Overview
|
|
39
|
+
|
|
40
|
+
Owe lets a group of users record debts and payments between each other.
|
|
41
|
+
The Summary tab shows the minimum set of transactions needed to settle all
|
|
42
|
+
outstanding balances, and provides a one-click Settle button for each.
|
|
43
|
+
|
|
44
|
+
Core concepts:
|
|
45
|
+
|
|
46
|
+
- Users - participants identified by email address.
|
|
47
|
+
- Records - individual transactions of type `DEBT` (someone owes money) or
|
|
48
|
+
`PAYMENT` (money has been physically transferred).
|
|
49
|
+
Records can be cancelled (invalidated) if entered by mistake.
|
|
50
|
+
- Summary - the net balances across all active records, reduced to the minimum
|
|
51
|
+
number of settlement transactions using
|
|
52
|
+
[a simple greedy algorithm](https://stackoverflow.com/a/15723286).
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Python 3.10 or later
|
|
57
|
+
- [uv](https://github.com/astral-sh/uv) (recommended) or pip
|
|
58
|
+
- (Optional) A Telegram bot token and chat ID for record notifications.
|
|
59
|
+
|
|
60
|
+
## Setup
|
|
61
|
+
|
|
62
|
+
You can get started with uv:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
# Sync dependencies
|
|
66
|
+
uv sync
|
|
67
|
+
# Run the development server
|
|
68
|
+
uv run flask --app owe run
|
|
69
|
+
# Or run the production server with gunicorn
|
|
70
|
+
uv run gunicorn "owe:create_app()"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or if you prefer pip:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
# Create a virtual environment
|
|
77
|
+
python3 -m venv .venv
|
|
78
|
+
# Activate the virtual environment
|
|
79
|
+
source .venv/bin/activate
|
|
80
|
+
# Install dependencies
|
|
81
|
+
pip install .
|
|
82
|
+
# Run the development server
|
|
83
|
+
flask --app owe run
|
|
84
|
+
# Or run the production server with gunicorn
|
|
85
|
+
gunicorn "owe:create_app()"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### App factory customization
|
|
89
|
+
|
|
90
|
+
The application exposes an app factory, `create_app`, with two optional
|
|
91
|
+
keyword arguments:
|
|
92
|
+
|
|
93
|
+
- `url_prefix`: Prefix all registered routes (e.g. `"/owe"`).
|
|
94
|
+
- `api_only`: Register only the API blueprint (skip the bundled UI).
|
|
95
|
+
|
|
96
|
+
For example, to run API-only routes under `/owe`:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
# With Flask
|
|
100
|
+
uv run flask --app "owe:create_app(api_only=True, url_prefix='/owe')" run
|
|
101
|
+
|
|
102
|
+
# With gunicorn
|
|
103
|
+
uv run gunicorn "owe:create_app(api_only=True, url_prefix='/owe')"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
With this configuration, API endpoints are served under `/owe/...`.
|
|
107
|
+
|
|
108
|
+
### Environment variables
|
|
109
|
+
|
|
110
|
+
The application can be configured with the following environment variables:
|
|
111
|
+
|
|
112
|
+
- `OWE_LOG_LEVEL`: Python logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`,
|
|
113
|
+
`CRITICAL`).
|
|
114
|
+
Default: `INFO`.
|
|
115
|
+
- `OWE_DATABASE`: Path to the SQLite database file.
|
|
116
|
+
Default: `owe.db`.
|
|
117
|
+
- `OWE_CURRENCY`: ISO 4217 currency code displayed in the UI.
|
|
118
|
+
Default: `USD`.
|
|
119
|
+
- `OWE_REQUEST_EMAIL_HEADER`: HTTP header to trust for the authenticated user's
|
|
120
|
+
email, to be used as the creator of records.
|
|
121
|
+
When run behind Cloudflare Access, this can be set to
|
|
122
|
+
`cf-access-authenticated-user-email` so that the application can identify the
|
|
123
|
+
user by their email address.
|
|
124
|
+
When unset, the remote IP address will be used to identify the user instead.
|
|
125
|
+
Default: unset.
|
|
126
|
+
- `OWE_TELEGRAM_BOT_TOKEN`: Telegram bot token for notifications.
|
|
127
|
+
Default: unset.
|
|
128
|
+
- `OWE_TELEGRAM_CHAT_ID`: Telegram chat ID for notifications.
|
|
129
|
+
Default: unset.
|
|
130
|
+
|
|
131
|
+
## Docker
|
|
132
|
+
|
|
133
|
+
Docker images are available on Docker Hub as
|
|
134
|
+
[`zhongruoyu/owe`](https://hub.docker.com/r/zhongruoyu/owe),
|
|
135
|
+
and on GitHub Container Registry as
|
|
136
|
+
[`ghcr.io/zhongruoyu/owe`](https://ghcr.io/zhongruoyu/owe).
|
|
137
|
+
The `main` tag tracks the latest commit on the main branch.
|
|
138
|
+
|
|
139
|
+
You may run the application with Docker as follows:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
docker run -p 8000:8000 \
|
|
143
|
+
-v /path/to/data:/data \
|
|
144
|
+
-e OWE_DATABASE=/data/owe.db \
|
|
145
|
+
-e OWE_CURRENCY=USD \
|
|
146
|
+
zhongruoyu/owe:main --bind "0.0.0.0:8000"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The container image runs gunicorn and accepts its command-line arguments, so you
|
|
150
|
+
can customize the server configuration (e.g. `--bind` for socket binding) using
|
|
151
|
+
standard gunicorn options; run with `--help` for details.
|
|
152
|
+
|
|
153
|
+
## API Endpoints
|
|
154
|
+
|
|
155
|
+
The server exposes the following API endpoints:
|
|
156
|
+
|
|
157
|
+
- `GET /api/config`: Get application configuration (e.g. currency).
|
|
158
|
+
- `GET /api/users`: List all users.
|
|
159
|
+
- `GET /api/records`: List all active and inactive records.
|
|
160
|
+
- `POST /api/records`: Create a new record.
|
|
161
|
+
Request body should be JSON with the following fields:
|
|
162
|
+
- `type`: "DEBT" or "PAYMENT"
|
|
163
|
+
- `lender`: email of the lender
|
|
164
|
+
- `borrowers`: list of emails of the borrowers
|
|
165
|
+
- `amount`: total amount (will be split evenly among borrowers)
|
|
166
|
+
- `remarks`: optional remarks for the record
|
|
167
|
+
- `PATCH /api/records/status`: Update the status of records.
|
|
168
|
+
Request body should be JSON with the following fields:
|
|
169
|
+
- `ids`: list of record IDs to update
|
|
170
|
+
- `active`: boolean indicating whether the records should be active or
|
|
171
|
+
inactive
|
|
172
|
+
- `GET /api/summary`:
|
|
173
|
+
Get the minimum set of settlement transactions.
|
|
174
|
+
|
|
175
|
+
## Utilities
|
|
176
|
+
|
|
177
|
+
The package also comes with two command-line utilities:
|
|
178
|
+
|
|
179
|
+
- `owe-dump`: Dump all records in the database as CSV.
|
|
180
|
+
Use `--output` to set the output file path (default: `records.csv`);
|
|
181
|
+
use `--database` to choose the SQLite database file (default: `owe.db`).
|
|
182
|
+
- `owe-users`: Manage users from the command line.
|
|
183
|
+
Use `--database` to choose the SQLite database file (default: `owe.db`).
|
|
184
|
+
Usage:
|
|
185
|
+
- `owe-users [--database <path>] create <email> <name>`:
|
|
186
|
+
Add a user with the given email and name.
|
|
187
|
+
- `owe-users [--database <path>] list`: List all users.
|
|
188
|
+
- `owe-users [--database <path>] activate <email>`:
|
|
189
|
+
Activate the user with the given email.
|
|
190
|
+
- `owe-users [--database <path>] deactivate <email>`:
|
|
191
|
+
Deactivate the user with the given email.
|
|
192
|
+
|
|
193
|
+
To run these utilities:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
# With uv
|
|
197
|
+
uv run owe-dump --output records.csv
|
|
198
|
+
uv run owe-users --database owe.db list
|
|
199
|
+
|
|
200
|
+
# With pip, after `pip install .`
|
|
201
|
+
owe-dump --output records.csv
|
|
202
|
+
owe-users --database owe.db list
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
This project is licensed under the MIT License.
|
|
208
|
+
See the [LICENSE](LICENSE) file for details.
|
owe-0.1.0/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Owe
|
|
2
|
+
|
|
3
|
+
Owe is a simple web application for splitting bills and tracking who owes whom
|
|
4
|
+
how much.
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
Owe lets a group of users record debts and payments between each other.
|
|
9
|
+
The Summary tab shows the minimum set of transactions needed to settle all
|
|
10
|
+
outstanding balances, and provides a one-click Settle button for each.
|
|
11
|
+
|
|
12
|
+
Core concepts:
|
|
13
|
+
|
|
14
|
+
- Users - participants identified by email address.
|
|
15
|
+
- Records - individual transactions of type `DEBT` (someone owes money) or
|
|
16
|
+
`PAYMENT` (money has been physically transferred).
|
|
17
|
+
Records can be cancelled (invalidated) if entered by mistake.
|
|
18
|
+
- Summary - the net balances across all active records, reduced to the minimum
|
|
19
|
+
number of settlement transactions using
|
|
20
|
+
[a simple greedy algorithm](https://stackoverflow.com/a/15723286).
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Python 3.10 or later
|
|
25
|
+
- [uv](https://github.com/astral-sh/uv) (recommended) or pip
|
|
26
|
+
- (Optional) A Telegram bot token and chat ID for record notifications.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
You can get started with uv:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
# Sync dependencies
|
|
34
|
+
uv sync
|
|
35
|
+
# Run the development server
|
|
36
|
+
uv run flask --app owe run
|
|
37
|
+
# Or run the production server with gunicorn
|
|
38
|
+
uv run gunicorn "owe:create_app()"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or if you prefer pip:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
# Create a virtual environment
|
|
45
|
+
python3 -m venv .venv
|
|
46
|
+
# Activate the virtual environment
|
|
47
|
+
source .venv/bin/activate
|
|
48
|
+
# Install dependencies
|
|
49
|
+
pip install .
|
|
50
|
+
# Run the development server
|
|
51
|
+
flask --app owe run
|
|
52
|
+
# Or run the production server with gunicorn
|
|
53
|
+
gunicorn "owe:create_app()"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### App factory customization
|
|
57
|
+
|
|
58
|
+
The application exposes an app factory, `create_app`, with two optional
|
|
59
|
+
keyword arguments:
|
|
60
|
+
|
|
61
|
+
- `url_prefix`: Prefix all registered routes (e.g. `"/owe"`).
|
|
62
|
+
- `api_only`: Register only the API blueprint (skip the bundled UI).
|
|
63
|
+
|
|
64
|
+
For example, to run API-only routes under `/owe`:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
# With Flask
|
|
68
|
+
uv run flask --app "owe:create_app(api_only=True, url_prefix='/owe')" run
|
|
69
|
+
|
|
70
|
+
# With gunicorn
|
|
71
|
+
uv run gunicorn "owe:create_app(api_only=True, url_prefix='/owe')"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
With this configuration, API endpoints are served under `/owe/...`.
|
|
75
|
+
|
|
76
|
+
### Environment variables
|
|
77
|
+
|
|
78
|
+
The application can be configured with the following environment variables:
|
|
79
|
+
|
|
80
|
+
- `OWE_LOG_LEVEL`: Python logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`,
|
|
81
|
+
`CRITICAL`).
|
|
82
|
+
Default: `INFO`.
|
|
83
|
+
- `OWE_DATABASE`: Path to the SQLite database file.
|
|
84
|
+
Default: `owe.db`.
|
|
85
|
+
- `OWE_CURRENCY`: ISO 4217 currency code displayed in the UI.
|
|
86
|
+
Default: `USD`.
|
|
87
|
+
- `OWE_REQUEST_EMAIL_HEADER`: HTTP header to trust for the authenticated user's
|
|
88
|
+
email, to be used as the creator of records.
|
|
89
|
+
When run behind Cloudflare Access, this can be set to
|
|
90
|
+
`cf-access-authenticated-user-email` so that the application can identify the
|
|
91
|
+
user by their email address.
|
|
92
|
+
When unset, the remote IP address will be used to identify the user instead.
|
|
93
|
+
Default: unset.
|
|
94
|
+
- `OWE_TELEGRAM_BOT_TOKEN`: Telegram bot token for notifications.
|
|
95
|
+
Default: unset.
|
|
96
|
+
- `OWE_TELEGRAM_CHAT_ID`: Telegram chat ID for notifications.
|
|
97
|
+
Default: unset.
|
|
98
|
+
|
|
99
|
+
## Docker
|
|
100
|
+
|
|
101
|
+
Docker images are available on Docker Hub as
|
|
102
|
+
[`zhongruoyu/owe`](https://hub.docker.com/r/zhongruoyu/owe),
|
|
103
|
+
and on GitHub Container Registry as
|
|
104
|
+
[`ghcr.io/zhongruoyu/owe`](https://ghcr.io/zhongruoyu/owe).
|
|
105
|
+
The `main` tag tracks the latest commit on the main branch.
|
|
106
|
+
|
|
107
|
+
You may run the application with Docker as follows:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
docker run -p 8000:8000 \
|
|
111
|
+
-v /path/to/data:/data \
|
|
112
|
+
-e OWE_DATABASE=/data/owe.db \
|
|
113
|
+
-e OWE_CURRENCY=USD \
|
|
114
|
+
zhongruoyu/owe:main --bind "0.0.0.0:8000"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The container image runs gunicorn and accepts its command-line arguments, so you
|
|
118
|
+
can customize the server configuration (e.g. `--bind` for socket binding) using
|
|
119
|
+
standard gunicorn options; run with `--help` for details.
|
|
120
|
+
|
|
121
|
+
## API Endpoints
|
|
122
|
+
|
|
123
|
+
The server exposes the following API endpoints:
|
|
124
|
+
|
|
125
|
+
- `GET /api/config`: Get application configuration (e.g. currency).
|
|
126
|
+
- `GET /api/users`: List all users.
|
|
127
|
+
- `GET /api/records`: List all active and inactive records.
|
|
128
|
+
- `POST /api/records`: Create a new record.
|
|
129
|
+
Request body should be JSON with the following fields:
|
|
130
|
+
- `type`: "DEBT" or "PAYMENT"
|
|
131
|
+
- `lender`: email of the lender
|
|
132
|
+
- `borrowers`: list of emails of the borrowers
|
|
133
|
+
- `amount`: total amount (will be split evenly among borrowers)
|
|
134
|
+
- `remarks`: optional remarks for the record
|
|
135
|
+
- `PATCH /api/records/status`: Update the status of records.
|
|
136
|
+
Request body should be JSON with the following fields:
|
|
137
|
+
- `ids`: list of record IDs to update
|
|
138
|
+
- `active`: boolean indicating whether the records should be active or
|
|
139
|
+
inactive
|
|
140
|
+
- `GET /api/summary`:
|
|
141
|
+
Get the minimum set of settlement transactions.
|
|
142
|
+
|
|
143
|
+
## Utilities
|
|
144
|
+
|
|
145
|
+
The package also comes with two command-line utilities:
|
|
146
|
+
|
|
147
|
+
- `owe-dump`: Dump all records in the database as CSV.
|
|
148
|
+
Use `--output` to set the output file path (default: `records.csv`);
|
|
149
|
+
use `--database` to choose the SQLite database file (default: `owe.db`).
|
|
150
|
+
- `owe-users`: Manage users from the command line.
|
|
151
|
+
Use `--database` to choose the SQLite database file (default: `owe.db`).
|
|
152
|
+
Usage:
|
|
153
|
+
- `owe-users [--database <path>] create <email> <name>`:
|
|
154
|
+
Add a user with the given email and name.
|
|
155
|
+
- `owe-users [--database <path>] list`: List all users.
|
|
156
|
+
- `owe-users [--database <path>] activate <email>`:
|
|
157
|
+
Activate the user with the given email.
|
|
158
|
+
- `owe-users [--database <path>] deactivate <email>`:
|
|
159
|
+
Deactivate the user with the given email.
|
|
160
|
+
|
|
161
|
+
To run these utilities:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
# With uv
|
|
165
|
+
uv run owe-dump --output records.csv
|
|
166
|
+
uv run owe-users --database owe.db list
|
|
167
|
+
|
|
168
|
+
# With pip, after `pip install .`
|
|
169
|
+
owe-dump --output records.csv
|
|
170
|
+
owe-users --database owe.db list
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
This project is licensed under the MIT License.
|
|
176
|
+
See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from flask import Flask
|
|
2
|
+
from flask_cors import CORS
|
|
3
|
+
|
|
4
|
+
from . import app as owe_app
|
|
5
|
+
from .config import load_env_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_app(
|
|
9
|
+
*,
|
|
10
|
+
url_prefix: str | None = None,
|
|
11
|
+
api_only: bool = False,
|
|
12
|
+
) -> Flask:
|
|
13
|
+
"""Create and configure the Flask application instance."""
|
|
14
|
+
app = Flask(__name__, static_folder=None)
|
|
15
|
+
app.config.update(load_env_config())
|
|
16
|
+
owe_app.init(app)
|
|
17
|
+
bp = owe_app.api if api_only else owe_app.app
|
|
18
|
+
app.register_blueprint(bp, url_prefix=url_prefix)
|
|
19
|
+
CORS(app)
|
|
20
|
+
return app
|
owe-0.1.0/owe/app.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sqlite3
|
|
3
|
+
from html import escape
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from flask import Blueprint, Flask, Response, current_app, request
|
|
8
|
+
|
|
9
|
+
from . import database, owe
|
|
10
|
+
from .config import AppConfigItems
|
|
11
|
+
from .record import AggregatedRecord
|
|
12
|
+
|
|
13
|
+
API_PREFIX = "/api"
|
|
14
|
+
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def app_config() -> AppConfigItems:
|
|
20
|
+
"""Return typed app config values for the current request context."""
|
|
21
|
+
return cast("AppConfigItems", current_app.config)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init(app: Flask) -> None:
|
|
25
|
+
"""Initialize logging and database schema for the app."""
|
|
26
|
+
config = cast("AppConfigItems", app.config)
|
|
27
|
+
logging.basicConfig(
|
|
28
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
29
|
+
level=getattr(logging, config["LOG_LEVEL"], logging.INFO),
|
|
30
|
+
)
|
|
31
|
+
database.init(config["DATABASE"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_requester() -> str:
|
|
35
|
+
"""Return requester identity from configured header or remote address."""
|
|
36
|
+
request_email_header = app_config()["REQUEST_EMAIL_HEADER"]
|
|
37
|
+
if request_email_header:
|
|
38
|
+
email = request.headers.get(request_email_header)
|
|
39
|
+
if email:
|
|
40
|
+
return email
|
|
41
|
+
return request.remote_addr or "unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
app = Blueprint(
|
|
45
|
+
"owe",
|
|
46
|
+
__name__,
|
|
47
|
+
url_prefix="",
|
|
48
|
+
static_folder=STATIC_DIR,
|
|
49
|
+
static_url_path="",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.after_request
|
|
54
|
+
def add_csp(response: Response) -> Response:
|
|
55
|
+
"""Attach a strict content security policy to outgoing responses."""
|
|
56
|
+
response.headers["Content-Security-Policy"] = (
|
|
57
|
+
"default-src 'self'; "
|
|
58
|
+
"script-src 'self' https://cdnjs.cloudflare.com; "
|
|
59
|
+
"style-src 'self' https://cdnjs.cloudflare.com; "
|
|
60
|
+
"img-src 'self' data:; "
|
|
61
|
+
)
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.route("/")
|
|
66
|
+
def index() -> Response:
|
|
67
|
+
"""Serve the single-page application entry point."""
|
|
68
|
+
return app.send_static_file("index.html")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
api = Blueprint("api", __name__, url_prefix=API_PREFIX)
|
|
72
|
+
app.register_blueprint(api)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@api.route("/config")
|
|
76
|
+
def get_config() -> dict[str, Any]:
|
|
77
|
+
"""Return client-facing configuration values."""
|
|
78
|
+
return {"currency": app_config()["CURRENCY"]}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@api.route("/users")
|
|
82
|
+
def get_users() -> list[dict[str, Any]]:
|
|
83
|
+
"""Return active users for UI selection."""
|
|
84
|
+
users = owe.get_active_users()
|
|
85
|
+
return [user.asdict() for user in users]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@api.route("/records")
|
|
89
|
+
def get_records() -> dict[str, dict[str, Any]]:
|
|
90
|
+
"""Return all records keyed by record ID as strings."""
|
|
91
|
+
records = owe.get_records()
|
|
92
|
+
return {str(record.id): record.asdict() for record in records}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_add_records_request( # noqa: PLR0911
|
|
96
|
+
req: dict[str, Any],
|
|
97
|
+
valid_emails: set[str],
|
|
98
|
+
) -> tuple[bool, str]:
|
|
99
|
+
"""Validate the add-record request body and referenced user emails."""
|
|
100
|
+
for key in ["type", "lender", "borrowers", "amount", "remarks"]:
|
|
101
|
+
if key not in req:
|
|
102
|
+
return False, f"Missing field: {key}"
|
|
103
|
+
|
|
104
|
+
req_type = req["type"]
|
|
105
|
+
if not isinstance(req_type, str) or req_type not in {"DEBT", "PAYMENT"}:
|
|
106
|
+
return False, 'type must be either "DEBT" or "PAYMENT"'
|
|
107
|
+
|
|
108
|
+
lender = req["lender"]
|
|
109
|
+
if not isinstance(lender, str):
|
|
110
|
+
return False, "lender must be a string"
|
|
111
|
+
|
|
112
|
+
borrowers = req["borrowers"]
|
|
113
|
+
if (
|
|
114
|
+
not isinstance(borrowers, list)
|
|
115
|
+
or not borrowers
|
|
116
|
+
or not all(isinstance(borrower, str) for borrower in borrowers)
|
|
117
|
+
):
|
|
118
|
+
return False, "borrowers must be a non-empty list of strings"
|
|
119
|
+
|
|
120
|
+
amount = req["amount"]
|
|
121
|
+
if not isinstance(amount, (int, float)) or amount <= 0:
|
|
122
|
+
return False, "amount must be a positive number"
|
|
123
|
+
|
|
124
|
+
remarks = req["remarks"]
|
|
125
|
+
if not isinstance(remarks, str) and remarks is not None:
|
|
126
|
+
return False, "remarks must be a string or null"
|
|
127
|
+
|
|
128
|
+
if lender not in valid_emails:
|
|
129
|
+
return False, f"Unknown lender: {escape(lender)}"
|
|
130
|
+
|
|
131
|
+
if unknown_borrowers := set(borrowers) - valid_emails:
|
|
132
|
+
borrowers_str = escape(
|
|
133
|
+
# https://github.com/astral-sh/ty/issues/521
|
|
134
|
+
", ".join(email for email in unknown_borrowers), # ty:ignore[no-matching-overload]
|
|
135
|
+
)
|
|
136
|
+
return False, f"Unknown borrower(s): {borrowers_str}"
|
|
137
|
+
|
|
138
|
+
return True, ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@api.route("/records", methods=["POST"])
|
|
142
|
+
def add_records() -> tuple[dict[str, Any], int]:
|
|
143
|
+
"""Create an aggregated record and persist its split entries."""
|
|
144
|
+
req = request.get_json()
|
|
145
|
+
if not req:
|
|
146
|
+
return {"success": False, "error": "Request body must be JSON"}, 400
|
|
147
|
+
|
|
148
|
+
users = owe.get_active_users()
|
|
149
|
+
valid_emails = {u.email for u in users}
|
|
150
|
+
valid, error = validate_add_records_request(req, valid_emails)
|
|
151
|
+
if not valid:
|
|
152
|
+
return {"success": False, "error": error}, 400
|
|
153
|
+
|
|
154
|
+
record = AggregatedRecord(
|
|
155
|
+
type=req["type"],
|
|
156
|
+
lender=req["lender"],
|
|
157
|
+
borrowers=req["borrowers"],
|
|
158
|
+
amount=req["amount"],
|
|
159
|
+
created_by=get_requester(),
|
|
160
|
+
remarks=req["remarks"],
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
owe.add_records(record)
|
|
164
|
+
except sqlite3.Error:
|
|
165
|
+
logger.exception("Database error in add_records")
|
|
166
|
+
return {"success": False, "error": "Database error"}, 500
|
|
167
|
+
|
|
168
|
+
return {"success": True}, 200
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@api.route("/records/status", methods=["PATCH"])
|
|
172
|
+
def set_records_active() -> tuple[dict[str, Any], int]:
|
|
173
|
+
"""Update the active flag for a batch of records."""
|
|
174
|
+
req = request.get_json()
|
|
175
|
+
if not req:
|
|
176
|
+
return {"success": False, "error": "Request body must be JSON"}, 400
|
|
177
|
+
|
|
178
|
+
ids = req.get("ids", [])
|
|
179
|
+
active = req.get("active")
|
|
180
|
+
if not ids or not all(isinstance(i, int) for i in ids):
|
|
181
|
+
return {
|
|
182
|
+
"success": False,
|
|
183
|
+
"error": "ids must be a non-empty list of integers",
|
|
184
|
+
}, 400
|
|
185
|
+
if not isinstance(active, bool):
|
|
186
|
+
return {"success": False, "error": "active must be a boolean"}, 400
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
owe.set_records_active(
|
|
190
|
+
ids,
|
|
191
|
+
active=active,
|
|
192
|
+
requester=get_requester(),
|
|
193
|
+
)
|
|
194
|
+
except sqlite3.Error:
|
|
195
|
+
logger.exception("Database error in set_records_active")
|
|
196
|
+
return {"success": False, "error": "Database error"}, 500
|
|
197
|
+
|
|
198
|
+
return {"success": True}, 200
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@api.route("/summary")
|
|
202
|
+
def summary() -> list[owe.SummaryTransaction]:
|
|
203
|
+
"""Return settlement transactions computed from net balances."""
|
|
204
|
+
return owe.get_summary()
|
owe-0.1.0/owe/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AppConfigItems(TypedDict):
|
|
7
|
+
LOG_LEVEL: str
|
|
8
|
+
DATABASE: Path
|
|
9
|
+
CURRENCY: str
|
|
10
|
+
REQUEST_EMAIL_HEADER: str | None
|
|
11
|
+
TELEGRAM_BOT_TOKEN: str | None
|
|
12
|
+
TELEGRAM_CHAT_ID: str | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_env_config() -> AppConfigItems:
|
|
16
|
+
"""Load application config values from environment variables."""
|
|
17
|
+
return {
|
|
18
|
+
"LOG_LEVEL": os.getenv("OWE_LOG_LEVEL", "INFO").upper(),
|
|
19
|
+
"DATABASE": Path(os.getenv("OWE_DATABASE", "owe.db")),
|
|
20
|
+
"CURRENCY": os.getenv("OWE_CURRENCY", "USD"),
|
|
21
|
+
"REQUEST_EMAIL_HEADER": os.getenv("OWE_REQUEST_EMAIL_HEADER"),
|
|
22
|
+
"TELEGRAM_BOT_TOKEN": os.getenv("OWE_TELEGRAM_BOT_TOKEN"),
|
|
23
|
+
"TELEGRAM_CHAT_ID": os.getenv("OWE_TELEGRAM_CHAT_ID"),
|
|
24
|
+
}
|