Compare commits

...

14 commits

Author SHA1 Message Date
7bfefe7d00
Merge f968c9870c into 83a60b1289 2024-12-20 18:07:57 +01:00
jjspill1
83a60b1289 Add a cli to count the number of active projects
Some checks are pending
CI / test (postgresql, normal, 3.11) (push) Blocked by required conditions
CI / test (postgresql, normal, 3.9) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.10) (push) Blocked by required conditions
CI / lint (push) Waiting to run
CI / test (mariadb, minimal, 3.11) (push) Blocked by required conditions
CI / test (mariadb, normal, 3.11) (push) Blocked by required conditions
CI / test (mariadb, normal, 3.9) (push) Blocked by required conditions
CI / test (postgresql, minimal, 3.11) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.11) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.12) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.9) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.10) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.11) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.12) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.8) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.9) (push) Blocked by required conditions
CI / docs (push) Waiting to run
Docker build / test (push) Waiting to run
Docker build / build_upload (push) Blocked by required conditions
2024-12-20 18:07:51 +01:00
dependabot[bot]
ce20f9adea Update myst-parser requirement from <3,>=2 to >=2,<5
Updates the requirements on [myst-parser](https://github.com/executablebooks/MyST-Parser) to permit the latest version.
- [Release notes](https://github.com/executablebooks/MyST-Parser/releases)
- [Changelog](https://github.com/executablebooks/MyST-Parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/executablebooks/MyST-Parser/compare/v2.0.0...v4.0.0)

---
updated-dependencies:
- dependency-name: myst-parser
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 17:46:40 +01:00
2b21795e94 ci: Pin runners to ubuntu 22.04 2024-12-20 17:46:02 +01:00
dependabot[bot]
0b09476865 Bump vermin from 1.5.2 to 1.6.0
Bumps [vermin](https://github.com/netromdk/vermin) from 1.5.2 to 1.6.0.
- [Release notes](https://github.com/netromdk/vermin/releases)
- [Commits](https://github.com/netromdk/vermin/compare/v1.5.2...v1.6.0)

---
updated-dependencies:
- dependency-name: vermin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 17:32:56 +01:00
dependabot[bot]
62fbeee15c Update pymysql requirement from <1.1,>=0.9 to >=0.9,<1.2
Updates the requirements on [pymysql](https://github.com/PyMySQL/PyMySQL) to permit the latest version.
- [Release notes](https://github.com/PyMySQL/PyMySQL/releases)
- [Changelog](https://github.com/PyMySQL/PyMySQL/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PyMySQL/PyMySQL/compare/v0.9.0...v1.1.1)

---
updated-dependencies:
- dependency-name: pymysql
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 17:32:50 +01:00
dependabot[bot]
56e2ff6900 Update wtforms requirement from <3.2,>=2.3.3 to >=2.3.3,<3.3
Updates the requirements on [wtforms](https://github.com/pallets-eco/wtforms) to permit the latest version.
- [Release notes](https://github.com/pallets-eco/wtforms/releases)
- [Changelog](https://github.com/pallets-eco/wtforms/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets-eco/wtforms/compare/2.3.3...3.2.1)

---
updated-dependencies:
- dependency-name: wtforms
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 17:32:42 +01:00
dependabot[bot]
35ac04be20 Update flask requirement from <3,>=2 to >=2,<4
Updates the requirements on [flask](https://github.com/pallets/flask) to permit the latest version.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/2.0.0...3.1.0)

---
updated-dependencies:
- dependency-name: flask
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 17:32:32 +01:00
dependabot[bot]
a9f211d3f6 Update sphinx requirement from <8,>=7.0.1 to >=7.0.1,<9
Updates the requirements on [sphinx](https://github.com/sphinx-doc/sphinx) to permit the latest version.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/v8.1.3/CHANGES.rst)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.0.1...v8.1.3)

---
updated-dependencies:
- dependency-name: sphinx
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 17:32:17 +01:00
e568bb05cc tests: remove libfake time from the tests
libfaketime and python-libfaketime seem to cause our CI to fail when
used in conjunction with `uv`. This changes the way the tests are done
so they don't require libfaketime anymore.
2024-12-20 17:17:31 +01:00
86eb9b8662 build: remove support for python 3.7 2024-12-20 17:17:31 +01:00
6e31a9c8b5 Upgrade tooling on the project.
- Replace black by ruff, as it's quicker ;
- Use `uv` wherever possible as a replacement for pip, as it's way faster to run, add an `uv.lock` file which will be synced before the releases and published here ;
- Remove tox, it's too complex for this project and can easily be replaced by `uv` ;
- Apply `ruff` formatting ;
- Update the makefile accordingly ;
- Update the CI accordingly
2024-12-20 17:17:31 +01:00
f968c9870c
FIXUP: some more work 2024-09-29 16:16:41 +02:00
14cc9b96d3
feat(tags): Add tags on bills
Tags can now be added in the description of a bill, using a hashtag
symbol (`#tagname`).

There is no way to "manage" the tags, for simplicity, they are part of
the "what" field, and are parsed via a regular expression.

Statistics have been updated to include tags per month.

Under the hood, a new `tag` table has been added.
2024-05-30 23:12:50 +02:00
23 changed files with 2452 additions and 455 deletions

View file

@ -1,26 +0,0 @@
name: Check doc
on:
push:
branches: [ 'master', 'stable-*' ]
pull_request:
branches: [ 'master', 'stable-*' ]
jobs:
test_doc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox
- name: Check we can generate documentation
run: tox -e docs

View file

@ -1,4 +1,4 @@
name: Lint & unit tests name: CI
on: on:
push: push:
@ -8,26 +8,20 @@ on:
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Install uv and set the python version
uses: actions/setup-python@v4 uses: astral-sh/setup-uv@v4
with: with:
python-version: "3.11" python-version: "3.11"
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox
- name: Run Lint - name: Run Lint
run: tox -e lint run: make lint
test: test:
# Dependency on linting to avoid running our expensive matrix test for nothing # Dependency on linting to avoid running our expensive matrix test for nothing
needs: lint needs: lint
runs-on: ubuntu-latest runs-on: ubuntu-22.04
# Use postgresql and MariaDB versions of Debian bookworm # Use postgresql and MariaDB versions of Debian bookworm
services: services:
postgres: postgres:
@ -53,10 +47,11 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
dependencies: [normal] dependencies: [normal]
database: [sqlite] database: [sqlite]
# Test other databases with only a few versions of Python (Debian bullseye has 3.9, bookworm has 3.11) # Test other databases with only a few versions of Python
# (Debian bullseye has 3.9, bookworm has 3.11)
include: include:
- python-version: 3.9 - python-version: 3.9
dependencies: normal dependencies: normal
@ -71,9 +66,6 @@ jobs:
dependencies: normal dependencies: normal
database: mariadb database: mariadb
# Try a few variants with the minimal versions supported # Try a few variants with the minimal versions supported
- python-version: 3.7
dependencies: minimal
database: sqlite
- python-version: 3.9 - python-version: 3.9
dependencies: minimal dependencies: minimal
database: sqlite database: sqlite
@ -95,32 +87,36 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Install uv and set the python version
uses: actions/setup-python@v4 uses: astral-sh/setup-uv@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- name: Change dependencies to minimal supported versions - name: Change dependencies to minimal supported versions
run: sed -i -e 's/>=/==/g; s/~=.*==\(.*\)/==\1/g; s/~=/==/g;' pyproject.toml # This sed comment installs the minimal supported version
# for all versions except for "requires-python"
# This is to detect that the minimum versions are really
# supported, in the CI
run: sed -i -e '/requires-python/!s/>=/==/g; /requires-python/!s/~=.*==\(.*\)/==\1/g; /requires-python/!s/~=/==/g;' pyproject.toml
if: matrix.dependencies == 'minimal' if: matrix.dependencies == 'minimal'
- name: Install dependencies - name: Run tests
run: | run: uv run --extra dev --extra database pytest .
python -m pip install --upgrade pip
python -m pip install tox
# Run tox using the version of Python in `PATH`
- name: Run Tox with sqlite
run: tox -e py
if: matrix.database == 'sqlite'
env: env:
TESTING_SQLALCHEMY_DATABASE_URI: 'sqlite:///budget.db' # Setup the DATABASE_URI depending on the matrix we are using.
- name: Run Tox with postgresql TESTING_SQLALCHEMY_DATABASE_URI: ${{
run: tox -e py matrix.database == 'sqlite'
if: matrix.database == 'postgresql' && 'sqlite:///budget.db'
env: || matrix.database == 'postgresql'
TESTING_SQLALCHEMY_DATABASE_URI: 'postgresql+psycopg2://postgres:ihatemoney@localhost:5432/ihatemoney_ci' && 'postgresql+psycopg2://postgres:ihatemoney@localhost:5432/ihatemoney_ci'
- name: Run Tox with mariadb || 'mysql+pymysql://root:ihatemoney@localhost:3306/ihatemoney_ci'
run: tox -e py }}
if: matrix.database == 'mariadb'
env: docs:
TESTING_SQLALCHEMY_DATABASE_URI: 'mysql+pymysql://root:ihatemoney@localhost:3306/ihatemoney_ci' runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v4
with:
python-version: "3.11"
- name: Build docs
run: make build-docs

View file

@ -10,7 +10,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
@ -19,7 +19,7 @@ jobs:
run: docker compose -f docker-compose.test.yml run sut run: docker compose -f docker-compose.test.yml run sut
build_upload: build_upload:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
needs: test needs: test
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
steps: steps:

View file

@ -5,7 +5,10 @@ This document describes changes between each past release.
## 6.2.0 (unreleased) ## 6.2.0 (unreleased)
- Add support for python 3.12 (#757) - Add support for python 3.12 (#757)
- Remove support for python 3.7
- Replace the black linter by ruff
- Replace virtualenv and pip by uv
- Remove tox
## 6.1.5 (2024-03-19) ## 6.1.5 (2024-03-19)

View file

@ -1,60 +1,40 @@
VIRTUALENV = python3 -m venv VIRTUALENV = uv venv
SPHINX_BUILDDIR = docs/_build
VENV := $(shell realpath $${VIRTUAL_ENV-.venv}) VENV := $(shell realpath $${VIRTUAL_ENV-.venv})
PYTHON = $(VENV)/bin/python3 BIN := uv tool run
PIP := uv pip
PYTHON = $(BIN)/python3
DEV_STAMP = $(VENV)/.dev_env_installed.stamp DEV_STAMP = $(VENV)/.dev_env_installed.stamp
INSTALL_STAMP = $(VENV)/.install.stamp INSTALL_STAMP = $(VENV)/.install.stamp
TEMPDIR := $(shell mktemp -d) TEMPDIR := $(shell mktemp -d)
ZOPFLIPNG := zopflipng ZOPFLIPNG := zopflipng
MAGICK_MOGRIFY := mogrify MAGICK_MOGRIFY := mogrify
.PHONY: all
all: install ## Alias for install
.PHONY: install
install: virtualenv pyproject.toml $(INSTALL_STAMP) ## Install dependencies
$(INSTALL_STAMP):
$(VENV)/bin/pip install -U pip
$(VENV)/bin/pip install -e .
touch $(INSTALL_STAMP)
.PHONY: virtualenv .PHONY: virtualenv
virtualenv: $(PYTHON) virtualenv: $(PYTHON)
$(PYTHON): $(PYTHON):
$(VIRTUALENV) $(VENV) $(VIRTUALENV) $(VENV)
.PHONY: install-dev
install-dev: virtualenv pyproject.toml $(INSTALL_STAMP) $(DEV_STAMP) ## Install development dependencies
$(DEV_STAMP): $(PYTHON)
$(VENV)/bin/pip install -Ue .[dev]
touch $(DEV_STAMP)
.PHONY: remove-install-stamp
remove-install-stamp:
rm $(INSTALL_STAMP)
.PHONY: update
update: remove-install-stamp install ## Update the dependencies
.PHONY: serve .PHONY: serve
serve: install build-translations ## Run the ihatemoney server serve: build-translations ## Run the ihatemoney server
@echo 'Running ihatemoney on http://localhost:5000' @echo 'Running ihatemoney on http://localhost:5000'
FLASK_DEBUG=1 FLASK_APP=ihatemoney.wsgi $(VENV)/bin/flask run --host=0.0.0.0 FLASK_DEBUG=1 FLASK_APP=ihatemoney.wsgi uv run flask run --host=0.0.0.0
.PHONY: test .PHONY: test
test: install-dev ## Run the tests test:
$(VENV)/bin/tox uv run --extra dev --extra database pytest .
.PHONY: black .PHONY: lint
black: install-dev ## Run the tests lint:
$(VENV)/bin/black --target-version=py37 . uv tool run ruff check .
uv tool run vermin --no-tips --violations -t=3.8- .
.PHONY: isort .PHONY: format
isort: install-dev ## Run the tests format:
$(VENV)/bin/isort . uv tool run ruff format .
.PHONY: release .PHONY: release
release: install-dev ## Release a new version (see https://ihatemoney.readthedocs.io/en/latest/contributing.html#how-to-release) release: # Release a new version (see https://ihatemoney.readthedocs.io/en/latest/contributing.html#how-to-release)
$(VENV)/bin/fullrelease uv run --extra dev fullreleas
.PHONY: compress-showcase .PHONY: compress-showcase
compress-showcase: compress-showcase:
@ -72,27 +52,30 @@ compress-assets: compress-showcase ## Compress static assets
.PHONY: build-translations .PHONY: build-translations
build-translations: ## Build the translations build-translations: ## Build the translations
$(VENV)/bin/pybabel compile -d ihatemoney/translations uv run --extra dev pybabel compile -d ihatemoney/translations
.PHONY: extract-translations .PHONY: extract-translations
extract-translations: ## Extract new translations from source code extract-translations: ## Extract new translations from source code
$(VENV)/bin/pybabel extract --add-comments "I18N:" --strip-comments --omit-header --no-location --mapping-file ihatemoney/babel.cfg -o ihatemoney/messages.pot ihatemoney uv run --extra dev pybabel extract --add-comments "I18N:" --strip-comments --omit-header --no-location --mapping-file ihatemoney/babel.cfg -o ihatemoney/messages.pot ihatemoney
$(VENV)/bin/pybabel update -i ihatemoney/messages.pot -d ihatemoney/translations/ uv run --extra dev pybabel update -i ihatemoney/messages.pot -d ihatemoney/translations/
.PHONY: create-database-revision .PHONY: create-database-revision
create-database-revision: ## Create a new database revision create-database-revision: ## Create a new database revision
@read -p "Please enter a message describing this revision: " rev_message; \ @read -p "Please enter a message describing this revision: " rev_message; \
$(PYTHON) -m ihatemoney.manage db migrate -d ihatemoney/migrations -m "$${rev_message}" uv run python -m ihatemoney.manage db migrate -d ihatemoney/migrations -m "$${rev_message}"
.PHONY: create-empty-database-revision .PHONY: create-empty-database-revision
create-empty-database-revision: ## Create an empty database revision create-empty-database-revision: ## Create an empty database revision
@read -p "Please enter a message describing this revision: " rev_message; \ @read -p "Please enter a message describing this revision: " rev_message; \
$(PYTHON) -m ihatemoney.manage db revision -d ihatemoney/migrations -m "$${rev_message}" uv run python -m ihatemoney.manage db revision -d ihatemoney/migrations -m "$${rev_message}"
.PHONY: clean .PHONY: clean
clean: ## Destroy the virtual environment clean: ## Destroy the virtual environment
rm -rf .venv rm -rf .venv
build-docs:
uv run --extra doc sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html
.PHONY: help .PHONY: help
help: ## Show the help indications help: ## Show the help indications
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

View file

@ -22,7 +22,7 @@ highly encouraged to do so.
## Requirements ## Requirements
- **Python**: version 3.7 to 3.12. - **Python**: version 3.8 to 3.12.
- **Backends**: SQLite, PostgreSQL, MariaDB (version 10.3.2 or above), - **Backends**: SQLite, PostgreSQL, MariaDB (version 10.3.2 or above),
Memory. Memory.

View file

@ -183,7 +183,7 @@ We are using [black](https://black.readthedocs.io/en/stable/) and
Python files in this project. Be sure to run it locally on your files. Python files in this project. Be sure to run it locally on your files.
To do so, just run: To do so, just run:
make black isort make lint
You can also integrate them with your dev environment (as a You can also integrate them with your dev environment (as a
*format-on-save* hook, for instance). *format-on-save* hook, for instance).

View file

@ -93,7 +93,7 @@ Some Paas (Platform-as-a-Service), provide a documentation or even a quick insta
«Ihatemoney» depends on: «Ihatemoney» depends on:
- **Python**: any version from 3.7 to 3.12 will work. - **Python**: any version from 3.8 to 3.12 will work.
- **A database backend**: choose among SQLite, PostgreSQL, MariaDB (>= - **A database backend**: choose among SQLite, PostgreSQL, MariaDB (>=
10.3.2). 10.3.2).
- **Virtual environment** (recommended): [python3-venv]{.title-ref} - **Virtual environment** (recommended): [python3-venv]{.title-ref}

View file

@ -1,6 +1,6 @@
from datetime import datetime
import decimal import decimal
from re import match from datetime import datetime
from re import findall, match
from types import SimpleNamespace from types import SimpleNamespace
import email_validator import email_validator
@ -39,7 +39,7 @@ from wtforms.validators import (
) )
from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag
from ihatemoney.utils import ( from ihatemoney.utils import (
em_surround, em_surround,
eval_arithmetic_expression, eval_arithmetic_expression,
@ -90,7 +90,6 @@ def get_billform_for(project, set_default=True, **kwargs):
class CommaDecimalField(DecimalField): class CommaDecimalField(DecimalField):
"""A class to deal with comma in Decimal Field""" """A class to deal with comma in Decimal Field"""
def process_formdata(self, value): def process_formdata(self, value):
@ -135,7 +134,8 @@ class EditProjectForm(FlaskForm):
_("New private code"), _("New private code"),
description=_("Enter a new code if you want to change it"), description=_("Enter a new code if you want to change it"),
) )
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()]) contact_email = StringField(_("Email"), validators=[
DataRequired(), Email()])
project_history = BooleanField(_("Enable project history")) project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history")) ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter() currency_helper = CurrencyConverter()
@ -228,7 +228,8 @@ class ImportProjectForm(FlaskForm):
"File", "File",
validators=[ validators=[
FileRequired(), FileRequired(),
FileAllowed(["json", "JSON", "csv", "CSV"], "Incorrect file format"), FileAllowed(["json", "JSON", "csv", "CSV"],
"Incorrect file format"),
], ],
description=_("Compatible with Cospend"), description=_("Compatible with Cospend"),
) )
@ -349,9 +350,11 @@ class ResetPasswordForm(FlaskForm):
class BillForm(FlaskForm): class BillForm(FlaskForm):
date = DateField(_("When?"), validators=[DataRequired()], default=datetime.now) date = DateField(_("When?"), validators=[
DataRequired()], default=datetime.now)
what = StringField(_("What?"), validators=[DataRequired()]) what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int) payer = SelectField(_("Who paid?"), validators=[
DataRequired()], coerce=int)
amount = CalculatorStringField(_("How much?"), validators=[DataRequired()]) amount = CalculatorStringField(_("How much?"), validators=[DataRequired()])
currency_helper = CurrencyConverter() currency_helper = CurrencyConverter()
original_currency = SelectField(_("Currency"), validators=[DataRequired()]) original_currency = SelectField(_("Currency"), validators=[DataRequired()])
@ -373,8 +376,30 @@ class BillForm(FlaskForm):
submit = SubmitField(_("Submit")) submit = SubmitField(_("Submit"))
submit2 = SubmitField(_("Submit and add a new one")) submit2 = SubmitField(_("Submit and add a new one"))
def parse_hashtags(self, project, what):
"""Handles the hashtags which can be optionally specified in the 'what'
field, using the `grocery #hash #otherhash` syntax.
Returns: the new "what" field (with hashtags stripped-out) and the list
of tags.
"""
hashtags = findall(r"#(\w+)", what)
if not hashtags:
return what, []
for tag in hashtags:
what = what.replace(f"#{tag}", "")
return what, hashtags
def export(self, project): def export(self, project):
return Bill( """This is triggered on bill creation.
"""
what, hashtags = self.parse_hashtags(project, self.what.data)
bill = Bill(
amount=float(self.amount.data), amount=float(self.amount.data),
date=self.date.data, date=self.date.data,
external_link=self.external_link.data, external_link=self.external_link.data,
@ -382,14 +407,17 @@ class BillForm(FlaskForm):
owers=Person.query.get_by_ids(self.payed_for.data, project), owers=Person.query.get_by_ids(self.payed_for.data, project),
payer_id=self.payer.data, payer_id=self.payer.data,
project_default_currency=project.default_currency, project_default_currency=project.default_currency,
what=self.what.data, what=what,
bill_type=self.bill_type.data, bill_type=self.bill_type.data,
) )
bill.set_tags(hashtags, project)
return bill
def save(self, bill, project): def save(self, bill, project):
what, hashtags = self.parse_hashtags(project, self.what.data)
bill.payer_id = self.payer.data bill.payer_id = self.payer.data
bill.amount = self.amount.data bill.amount = self.amount.data
bill.what = self.what.data bill.what = what
bill.bill_type = BillType(self.bill_type.data) bill.bill_type = BillType(self.bill_type.data)
bill.external_link = self.external_link.data bill.external_link = self.external_link.data
bill.date = self.date.data bill.date = self.date.data
@ -398,19 +426,22 @@ class BillForm(FlaskForm):
bill.converted_amount = self.currency_helper.exchange_currency( bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency bill.amount, bill.original_currency, project.default_currency
) )
bill.set_tags(hashtags, project)
return bill return bill
def fill(self, bill, project): def fill(self, bill, project):
self.payer.data = bill.payer_id self.payer.data = bill.payer_id
self.amount.data = bill.amount self.amount.data = bill.amount
self.what.data = bill.what hashtags = ' '.join([f'#{tag.name}' for tag in bill.tags])
self.what.data = bill.what.strip() + f' {hashtags}'
self.bill_type.data = bill.bill_type self.bill_type.data = bill.bill_type
self.external_link.data = bill.external_link self.external_link.data = bill.external_link
self.original_currency.data = bill.original_currency self.original_currency.data = bill.original_currency
self.date.data = bill.date self.date.data = bill.date
self.payed_for.data = [int(ower.id) for ower in bill.owers] self.payed_for.data = [int(ower.id) for ower in bill.owers]
self.original_currency.label = Label("original_currency", _("Currency")) self.original_currency.label = Label(
"original_currency", _("Currency"))
self.original_currency.description = _( self.original_currency.description = _(
"Project default: %(currency)s", "Project default: %(currency)s",
currency=render_localized_currency( currency=render_localized_currency(
@ -439,10 +470,13 @@ class BillForm(FlaskForm):
class MemberForm(FlaskForm): class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter]) name = StringField(_("Name"), validators=[
DataRequired()], filters=[strip_filter])
weight_validators = [NumberRange(min=0.1, message=_("Weights should be positive"))] weight_validators = [NumberRange(
weight = CommaDecimalField(_("Weight"), default=1, validators=weight_validators) min=0.1, message=_("Weights should be positive"))]
weight = CommaDecimalField(
_("Weight"), default=1, validators=weight_validators)
submit = SubmitField(_("Add")) submit = SubmitField(_("Add"))
def __init__(self, project, edit=False, *args, **kwargs): def __init__(self, project, edit=False, *args, **kwargs):
@ -461,7 +495,8 @@ class MemberForm(FlaskForm):
Person.activated, Person.activated,
).all() ).all()
): # NOQA ): # NOQA
raise ValidationError(_("This project already have this participant")) raise ValidationError(
_("This project already have this participant"))
def save(self, project, person): def save(self, project, person):
# if the user is already bound to the project, just reactivate him # if the user is already bound to the project, just reactivate him

View file

@ -4,6 +4,7 @@ import getpass
import os import os
import random import random
import sys import sys
import datetime
import click import click
from flask.cli import FlaskGroup from flask.cli import FlaskGroup
@ -93,5 +94,31 @@ def delete_project(project_name):
db.session.commit() db.session.commit()
@cli.command()
@click.argument("print_emails", default=False)
@click.argument("bills", default=0) # default values will get total projects
@click.argument("days", default=73000) # approximately 200 years
def get_project_count(print_emails, bills, days):
"""Count projets with at least x bills and at less than x days old"""
projects = [
pr
for pr in Project.query.all()
if pr.get_bills().count() > bills
and pr.get_bills()[0].date
> datetime.date.today() - datetime.timedelta(days=days)
]
click.secho("Number of projects: " + str(len(projects)))
if print_emails:
emails = set([pr.contact_email for pr in projects])
emails_str = ", ".join(emails)
if len(emails) > 1:
click.secho("Contact emails: " + emails_str)
elif len(emails) == 1:
click.secho("Contact email: " + emails_str)
else:
click.secho("No contact emails found")
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View file

@ -0,0 +1,91 @@
"""Add a tags table
Revision ID: d53fe61e5521
Revises: 7a9b38559992
Create Date: 2024-05-16 00:32:19.566457
"""
# revision identifiers, used by Alembic.
revision = 'd53fe61e5521'
down_revision = '7a9b38559992'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('billtags_version',
sa.Column('bill_id', sa.Integer(), autoincrement=False, nullable=False),
sa.Column('tag_id', sa.Integer(), autoincrement=False, nullable=False),
sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
sa.Column('operation_type', sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint('bill_id', 'tag_id', 'transaction_id')
)
with op.batch_alter_table('billtags_version', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_billtags_version_end_transaction_id'), ['end_transaction_id'], unique=False)
batch_op.create_index(batch_op.f('ix_billtags_version_operation_type'), ['operation_type'], unique=False)
batch_op.create_index(batch_op.f('ix_billtags_version_transaction_id'), ['transaction_id'], unique=False)
op.create_table('tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.String(length=64), nullable=True),
sa.Column('name', sa.UnicodeText(), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id'),
sqlite_autoincrement=True
)
op.create_table('billtags',
sa.Column('bill_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['bill_id'], ['bill.id'], ),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ),
sa.PrimaryKeyConstraint('bill_id', 'tag_id'),
sqlite_autoincrement=True
)
with op.batch_alter_table('bill_version', schema=None) as batch_op:
batch_op.alter_column('bill_type',
existing_type=sa.TEXT(),
type_=sa.Enum('EXPENSE', 'REIMBURSEMENT', name='billtype'),
existing_nullable=True,
autoincrement=False)
with op.batch_alter_table('billowers', schema=None) as batch_op:
batch_op.alter_column('bill_id',
existing_type=sa.INTEGER(),
nullable=False)
batch_op.alter_column('person_id',
existing_type=sa.INTEGER(),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('billowers', schema=None) as batch_op:
batch_op.alter_column('person_id',
existing_type=sa.INTEGER(),
nullable=True)
batch_op.alter_column('bill_id',
existing_type=sa.INTEGER(),
nullable=True)
with op.batch_alter_table('bill_version', schema=None) as batch_op:
batch_op.alter_column('bill_type',
existing_type=sa.Enum('EXPENSE', 'REIMBURSEMENT', name='billtype'),
type_=sa.TEXT(),
existing_nullable=True,
autoincrement=False)
op.drop_table('billtags')
op.drop_table('tag')
with op.batch_alter_table('billtags_version', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_billtags_version_transaction_id'))
batch_op.drop_index(batch_op.f('ix_billtags_version_operation_type'))
batch_op.drop_index(batch_op.f('ix_billtags_version_end_transaction_id'))
op.drop_table('billtags_version')
# ### end Alembic commands ###

View file

@ -1,8 +1,9 @@
from collections import defaultdict
import datetime import datetime
from enum import Enum
import itertools import itertools
from collections import defaultdict
from enum import Enum
import sqlalchemy
from dateutil.parser import parse from dateutil.parser import parse
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from debts import settle from debts import settle
@ -14,7 +15,6 @@ from itsdangerous import (
URLSafeSerializer, URLSafeSerializer,
URLSafeTimedSerializer, URLSafeTimedSerializer,
) )
import sqlalchemy
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy_continuum import make_versioned, version_class from sqlalchemy_continuum import make_versioned, version_class
@ -125,7 +125,8 @@ class Project(db.Model):
balance spent paid balance spent paid
""" """
balances, should_pay, should_receive = (defaultdict(int) for time in (1, 2, 3)) balances, should_pay, should_receive = (
defaultdict(int) for time in (1, 2, 3))
for bill in self.get_bills_unordered().all(): for bill in self.get_bills_unordered().all():
total_weight = sum(ower.weight for ower in bill.owers) total_weight = sum(ower.weight for ower in bill.owers)
@ -181,11 +182,28 @@ class Project(db.Model):
:rtype dict: :rtype dict:
""" """
monthly = defaultdict(lambda: defaultdict(float)) monthly = defaultdict(lambda: defaultdict(float))
for bill in self.get_bills_unordered().all(): for bill in self.get_bills_unordered().all():
if bill.bill_type == BillType.EXPENSE: if bill.bill_type == BillType.EXPENSE:
monthly[bill.date.year][bill.date.month] += bill.converted_amount monthly[bill.date.year][bill.date.month] += bill.converted_amount
return monthly return monthly
@property
def tags_monthly_stats(self):
"""
:return: a dict of years mapping to a dict of months mapping to the amount
:rtype dict:
"""
tags_monthly = defaultdict(
lambda: defaultdict(lambda: defaultdict(float)))
for bill in self.get_bills_unordered().all():
if bill.bill_type == BillType.EXPENSE:
for tag in bill.tags:
tags_monthly[bill.date.year][bill.date.month][tag.name] += bill.converted_amount
return tags_monthly
@property @property
def uses_weights(self): def uses_weights(self):
return len([i for i in self.members if i.weight != 1]) > 0 return len([i for i in self.members if i.weight != 1]) > 0
@ -322,7 +340,8 @@ class Project(db.Model):
year=newest_date.year, month=newest_date.month, day=1 year=newest_date.year, month=newest_date.month, day=1
) )
# Infinite iterator towards the past # Infinite iterator towards the past
all_months = (newest_month - relativedelta(months=i) for i in itertools.count()) all_months = (newest_month - relativedelta(months=i)
for i in itertools.count())
# Stop when reaching one month before the first date # Stop when reaching one month before the first date
months = itertools.takewhile( months = itertools.takewhile(
lambda x: x > oldest_date - relativedelta(months=1), all_months lambda x: x > oldest_date - relativedelta(months=1), all_months
@ -497,7 +516,8 @@ class Project(db.Model):
) )
loads_kwargs["max_age"] = max_age loads_kwargs["max_age"] = max_age
else: else:
project = Project.query.get(project_id) if project_id is not None else None project = Project.query.get(
project_id) if project_id is not None else None
password = project.password if project is not None else "" password = project.password if project is not None else ""
serializer = URLSafeSerializer( serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"] + password, salt=token_type current_app.config["SECRET_KEY"] + password, salt=token_type
@ -643,8 +663,50 @@ class Person(db.Model):
# We need to manually define a join table for m2m relations # We need to manually define a join table for m2m relations
billowers = db.Table( billowers = db.Table(
"billowers", "billowers",
db.Column("bill_id", db.Integer, db.ForeignKey("bill.id"), primary_key=True), db.Column("bill_id", db.Integer, db.ForeignKey(
db.Column("person_id", db.Integer, db.ForeignKey("person.id"), primary_key=True), "bill.id"), primary_key=True),
db.Column("person_id", db.Integer, db.ForeignKey(
"person.id"), primary_key=True),
sqlite_autoincrement=True,
)
class Tag(db.Model):
class TagQuery(BaseQuery):
def get_or_create(self, name, project):
exists = (
Tag.query.filter(Tag.name == name)
.filter(Tag.project_id == project.id)
.one_or_none()
)
if exists:
return exists
return Tag(name=name, project_id=project.id)
query_class = TagQuery
__versionned__ = {}
__table_args__ = {"sqlite_autoincrement": True}
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.String(64), db.ForeignKey("project.id"))
# bills = db.relationship("Bill", backref="tags")
name = db.Column(db.UnicodeText)
def __str__(self):
return self.name
def __repr__(self):
return self.name
# We need to manually define a join table for m2m relations
billtags = db.Table(
"billtags",
db.Column("bill_id", db.Integer, db.ForeignKey(
"bill.id"), primary_key=True),
db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True),
sqlite_autoincrement=True, sqlite_autoincrement=True,
) )
@ -688,6 +750,7 @@ class Bill(db.Model):
what = db.Column(db.UnicodeText) what = db.Column(db.UnicodeText)
bill_type = db.Column(db.Enum(BillType)) bill_type = db.Column(db.Enum(BillType))
external_link = db.Column(db.UnicodeText) external_link = db.Column(db.UnicodeText)
tags = db.relationship(Tag, secondary=billtags)
original_currency = db.Column(db.String(3)) original_currency = db.Column(db.String(3))
converted_amount = db.Column(db.Float) converted_amount = db.Column(db.Float)
@ -752,15 +815,24 @@ class Bill(db.Model):
else: else:
return 0 return 0
def __str__(self):
return self.what
def pay_each(self): def pay_each(self):
"""Warning: this is slow, if you need to compute this for many bills, do """Warning: this is slow, if you need to compute this for many bills, do
it differently (see balance_full function) it differently (see balance_full function)
""" """
return self.pay_each_default(self.converted_amount) return self.pay_each_default(self.converted_amount)
def set_tags(self, tags, project):
object_tags = []
for tag_name in tags:
tag = Tag.query.get_or_create(name=tag_name, project=project)
db.session.add(tag)
object_tags.append(tag)
self.tags = object_tags
db.session.commit()
def __str__(self):
return self.what
def __repr__(self): def __repr__(self):
return ( return (
f"<Bill of {self.amount} from {self.payer} for " f"<Bill of {self.amount} from {self.payer} for "
@ -790,3 +862,4 @@ sqlalchemy.orm.configure_mappers()
PersonVersion = version_class(Person) PersonVersion = version_class(Person)
ProjectVersion = version_class(Project) ProjectVersion = version_class(Project)
BillVersion = version_class(Bill) BillVersion = version_class(Bill)
# TagVersion = version_class(Tag)

View file

@ -103,7 +103,7 @@ def validate_configuration(app):
if "MAIL_DEFAULT_SENDER" not in app.config: if "MAIL_DEFAULT_SENDER" not in app.config:
app.config["MAIL_DEFAULT_SENDER"] = default_settings.DEFAULT_MAIL_SENDER app.config["MAIL_DEFAULT_SENDER"] = default_settings.DEFAULT_MAIL_SENDER
if type(app.config["MAIL_DEFAULT_SENDER"]) == tuple: if type(app.config["MAIL_DEFAULT_SENDER"]) is tuple:
(name, address) = app.config["MAIL_DEFAULT_SENDER"] (name, address) = app.config["MAIL_DEFAULT_SENDER"]
app.config["MAIL_DEFAULT_SENDER"] = f"{name} <{address}>" app.config["MAIL_DEFAULT_SENDER"] = f"{name} <{address}>"
warnings.warn( warnings.warn(

View file

@ -116,12 +116,18 @@
{% if bills.total > 0 %} {% if bills.total > 0 %}
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm"> <table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
<thead> <thead>
<tr><th>{{ _("When?") }} <tr><th>{{ _("When?") }}</th>
</th><th>{{ _("Who paid?") }} <th>{{ _("Who paid?") }}</th>
</th><th>{{ _("For what?") }} <th>{{ _("For what?") }}</th>
</th><th>{{ _("For whom?") }} <th>{{ _("For whom?") }}</th>
</th><th>{{ _("How much?") }} <th>{{ _("How much?") }}</th>
</th><th>{{ _("Actions") }}</th></tr> <th data-toggle="tooltip"
data-placement="top"
title="{{ _('You can add tags to your bills by appending a #hashtag') }}">
{{ _("Tags") }}
</th>
<th>{{ _("Actions") }}</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for (weights, bill) in bills.items %} {% for (weights, bill) in bills.items %}
@ -147,6 +153,11 @@
{{ weighted_bill_amount(bill, weights) }} {{ weighted_bill_amount(bill, weights) }}
</span> </span>
</td> </td>
<td>
{% for tag in bill.tags %}
#{{ tag.name }}
{% endfor %}
</td>
<td class="bill-actions d-flex align-items-center"> <td class="bill-actions d-flex align-items-center">
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a> <a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
<form class="delete-bill" action="{{ url_for(".delete_bill", bill_id=bill.id) }}" method="POST"> <form class="delete-bill" action="{{ url_for(".delete_bill", bill_id=bill.id) }}" method="POST">

View file

@ -23,12 +23,28 @@
</table> </table>
<h2>{{ _("Expenses by Month") }}</h2> <h2>{{ _("Expenses by Month") }}</h2>
<table id="monthly_stats" class="table table-striped"> <table id="monthly_stats" class="table table-striped">
<thead><tr><th>{{ _("Period") }}</th><th>{{ _("Spent") }}</th></tr></thead> <thead>
<tr>
<th>{{ _("Period") }}</th>
<th>{{ _("Spent") }}</th>
{% for tag in tags %}
<th>#{{ tag.name }}</th>
{% endfor %}
</tr>
</thead>
<tbody> <tbody>
{% for month in months %} {% for month in months %}
<tr> <tr>
<td>{{ month|dateformat("MMMM yyyy") }}</td> <td>{{ month|dateformat("MMMM yyyy") }}</td>
<td>{{ monthly_stats[month.year][month.month]|currency }}</td> <td>{{ monthly_stats[month.year][month.month]|currency }}</td>
{% for tag in tags %}
{% if tag.name in tags_monthly_stats[month.year][month.month] %}
<td>{{ tags_monthly_stats[month.year][month.month][tag.name]|currency }}</td>
{% else %}
<td> - </td>
{% endif %}
{% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -9,7 +9,6 @@ from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
class TestAPI(IhatemoneyTestCase): class TestAPI(IhatemoneyTestCase):
"""Tests the API""" """Tests the API"""
def api_create( def api_create(

View file

@ -1,10 +1,9 @@
from collections import defaultdict from collections import defaultdict
import datetime from datetime import datetime, timedelta, date
import re import re
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
from flask import session, url_for from flask import session, url_for
from libfaketime import fake_time
import pytest import pytest
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
@ -1030,9 +1029,7 @@ class TestBudget(IhatemoneyTestCase):
assert """<thead> assert """<thead>
<tr> <tr>
<th>Project</th> <th>Project</th>
<th>Number of participants</th>""" in resp.data.decode( <th>Number of participants</th>""" in resp.data.decode("utf-8")
"utf-8"
)
def test_dashboard_project_deletion(self): def test_dashboard_project_deletion(self):
self.post_project("raclette") self.post_project("raclette")
@ -1146,7 +1143,7 @@ class TestBudget(IhatemoneyTestCase):
assert re.search(re.compile(regex2, re.DOTALL), response.data.decode("utf-8")) assert re.search(re.compile(regex2, re.DOTALL), response.data.decode("utf-8"))
# Check monthly expenses again: it should have a single month and the correct amount # Check monthly expenses again: it should have a single month and the correct amount
august = datetime.date(year=2011, month=8, day=1) august = date(year=2011, month=8, day=1)
assert project.active_months_range() == [august] assert project.active_months_range() == [august]
assert dict(project.monthly_stats[2011]) == {8: 40.0} assert dict(project.monthly_stats[2011]) == {8: 40.0}
@ -1163,11 +1160,11 @@ class TestBudget(IhatemoneyTestCase):
}, },
) )
months = [ months = [
datetime.date(year=2011, month=12, day=1), date(year=2011, month=12, day=1),
datetime.date(year=2011, month=11, day=1), date(year=2011, month=11, day=1),
datetime.date(year=2011, month=10, day=1), date(year=2011, month=10, day=1),
datetime.date(year=2011, month=9, day=1), date(year=2011, month=9, day=1),
datetime.date(year=2011, month=8, day=1), date(year=2011, month=8, day=1),
] ]
amounts_2011 = { amounts_2011 = {
12: 30.0, 12: 30.0,
@ -1220,7 +1217,7 @@ class TestBudget(IhatemoneyTestCase):
"amount": "20", "amount": "20",
}, },
) )
months.append(datetime.date(year=2011, month=7, day=1)) months.append(date(year=2011, month=7, day=1))
amounts_2011[7] = 20.0 amounts_2011[7] = 20.0
assert project.active_months_range() == months assert project.active_months_range() == months
assert dict(project.monthly_stats[2011]) == amounts_2011 assert dict(project.monthly_stats[2011]) == amounts_2011
@ -1237,7 +1234,7 @@ class TestBudget(IhatemoneyTestCase):
"amount": "30", "amount": "30",
}, },
) )
months.insert(0, datetime.date(year=2012, month=1, day=1)) months.insert(0, date(year=2012, month=1, day=1))
amounts_2012 = {1: 30.0} amounts_2012 = {1: 30.0}
assert project.active_months_range() == months assert project.active_months_range() == months
assert dict(project.monthly_stats[2011]) == amounts_2011 assert dict(project.monthly_stats[2011]) == amounts_2011
@ -1870,7 +1867,6 @@ class TestBudget(IhatemoneyTestCase):
""" """
Tests that the RSS feed output content is expected. Tests that the RSS feed output content is expected.
""" """
with fake_time("2023-07-25 12:00:00"):
self.post_project("raclette", default_currency="EUR") self.post_project("raclette", default_currency="EUR")
self.client.post("/raclette/members/add", data={"name": "george"}) self.client.post("/raclette/members/add", data={"name": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"}) self.client.post("/raclette/members/add", data={"name": "peter"})
@ -1917,12 +1913,10 @@ class TestBudget(IhatemoneyTestCase):
token = project.generate_token("feed") token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml") resp = self.client.get(f"/raclette/feed/{token}.xml")
expected_rss_content = f"""<?xml version="1.0" encoding="utf-8"?> content = resp.data.decode()
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/" assert (
xmlns:atom="http://www.w3.org/2005/Atom" f"""<channel>
>
<channel>
<title>I Hate Money raclette</title> <title>I Hate Money raclette</title>
<description>Latest bills from raclette</description> <description>Latest bills from raclette</description>
<atom:link href="http://localhost/raclette/feed/{token}.xml" rel="self" type="application/rss+xml" /> <atom:link href="http://localhost/raclette/feed/{token}.xml" rel="self" type="application/rss+xml" />
@ -1932,32 +1926,18 @@ class TestBudget(IhatemoneyTestCase):
<guid isPermaLink="false">1</guid> <guid isPermaLink="false">1</guid>
<dc:creator>george</dc:creator> <dc:creator>george</dc:creator>
<description>December 31, 2016 - george, peter, steven : 4.00</description> <description>December 31, 2016 - george, peter, steven : 4.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate> """
</item> in content
<item> )
<title>charcuterie - 15.00</title>
<guid isPermaLink="false">2</guid> assert """<title>charcuterie - €15.00</title>""" in content
<dc:creator>peter</dc:creator> assert """<title>vin blanc - €10.00</title>""" in content
<description>December 30, 2016 - george, peter : 7.50</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
<item>
<title>vin blanc - 10.00</title>
<guid isPermaLink="false">3</guid>
<dc:creator>peter</dc:creator>
<description>December 29, 2016 - george, peter : 5.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
</channel>
</rss>""" # noqa: E221, E222, E231, E501
assert resp.data.decode() == expected_rss_content
def test_rss_feed_history_disabled(self): def test_rss_feed_history_disabled(self):
""" """
Tests that RSS feeds is correctly rendered even if the project Tests that RSS feeds is correctly rendered even if the project
history is disabled. history is disabled.
""" """
with fake_time("2023-07-25 12:00:00"):
self.post_project("raclette", default_currency="EUR", project_history=False) self.post_project("raclette", default_currency="EUR", project_history=False)
self.client.post("/raclette/members/add", data={"name": "george"}) self.client.post("/raclette/members/add", data={"name": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"}) self.client.post("/raclette/members/add", data={"name": "peter"})
@ -2004,44 +1984,12 @@ class TestBudget(IhatemoneyTestCase):
token = project.generate_token("feed") token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml") resp = self.client.get(f"/raclette/feed/{token}.xml")
expected_rss_content = f"""<?xml version="1.0" encoding="utf-8"?> content = resp.data.decode()
<rss version="2.0" assert """<title>charcuterie - €15.00</title>""" in content
xmlns:dc="http://purl.org/dc/elements/1.1/" assert """<title>vin blanc - €10.00</title>""" in content
xmlns:atom="http://www.w3.org/2005/Atom"
>
<channel>
<title>I Hate Money raclette</title>
<description>Latest bills from raclette</description>
<atom:link href="http://localhost/raclette/feed/{token}.xml" rel="self" type="application/rss+xml" />
<link>http://localhost/raclette/</link>
<item>
<title>fromage à raclette - 12.00</title>
<guid isPermaLink="false">1</guid>
<dc:creator>george</dc:creator>
<description>December 31, 2016 - george, peter, steven : 4.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
<item>
<title>charcuterie - 15.00</title>
<guid isPermaLink="false">2</guid>
<dc:creator>peter</dc:creator>
<description>December 30, 2016 - george, peter : 7.50</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
<item>
<title>vin blanc - 10.00</title>
<guid isPermaLink="false">3</guid>
<dc:creator>peter</dc:creator>
<description>December 29, 2016 - george, peter : 5.00</description>
<pubDate>Tue, 25 Jul 2023 00:00:00 +0000</pubDate>
</item>
</channel>
</rss>""" # noqa: E221, E222, E231, E501
assert resp.data.decode() == expected_rss_content
def test_rss_if_modified_since_header(self): def test_rss_if_modified_since_header(self):
# Project creation # Project creation
with fake_time("2023-07-26 13:00:00"):
self.post_project("raclette") self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"}) self.client.post("/raclette/members/add", data={"name": "george"})
project = self.get_project("raclette") project = self.get_project("raclette")
@ -2049,22 +1997,33 @@ class TestBudget(IhatemoneyTestCase):
resp = self.client.get(f"/raclette/feed/{token}.xml") resp = self.client.get(f"/raclette/feed/{token}.xml")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.headers.get("Last-Modified") == "Wed, 26 Jul 2023 13:00:00 UTC" assert "Last-Modified" in resp.headers.keys()
last_modified = resp.headers.get("Last-Modified")
# Get a date 1 hour before the last modified date
before = datetime.strptime(
last_modified, "%a, %d %b %Y %H:%M:%S %Z"
) - timedelta(hours=1)
before_str = before.strftime("%a, %d %b %Y %H:%M:%S %Z")
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 26 Jul 2023 12:00:00 UTC"}, headers={"If-Modified-Since": before_str},
) )
assert resp.status_code == 200 assert resp.status_code == 200
after = datetime.strptime(
last_modified, "%a, %d %b %Y %H:%M:%S %Z"
) + timedelta(hours=1)
after_str = after.strftime("%a, %d %b %Y %H:%M:%S %Z")
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 26 Jul 2023 14:00:00 UTC"}, headers={"If-Modified-Since": after_str},
) )
assert resp.status_code == 304 assert resp.status_code == 304
# Add bill # Add bill
with fake_time("2023-07-27 13:00:00"):
self.login("raclette") self.login("raclette")
resp = self.client.post( resp = self.client.post(
"/raclette/add", "/raclette/add",
@ -2084,38 +2043,34 @@ class TestBudget(IhatemoneyTestCase):
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 27 Jul 2023 12:00:00 UTC"}, headers={"If-Modified-Since": before_str},
) )
assert resp.headers.get("Last-Modified") == "Thu, 27 Jul 2023 13:00:00 UTC"
assert resp.status_code == 200 assert resp.status_code == 200
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
headers={"If-Modified-Since": "Tue, 27 Jul 2023 14:00:00 UTC"}, headers={"If-Modified-Since": after_str},
) )
assert resp.status_code == 304 assert resp.status_code == 304
def test_rss_etag_headers(self): def test_rss_etag_headers(self):
# Project creation # Project creation
with fake_time("2023-07-26 13:00:00"):
self.post_project("raclette") self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"}) self.client.post("/raclette/members/add", data={"name": "george"})
project = self.get_project("raclette") project = self.get_project("raclette")
token = project.generate_token("feed") token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml") resp = self.client.get(f"/raclette/feed/{token}.xml")
assert resp.headers.get("ETag") == build_etag( etag = resp.headers.get("ETag")
project.id, "2023-07-26T13:00:00"
)
assert resp.status_code == 200 assert resp.status_code == 200
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
headers={ headers={
"If-None-Match": build_etag(project.id, "2023-07-26T12:00:00"), "If-None-Match": etag,
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 304
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
@ -2123,10 +2078,9 @@ class TestBudget(IhatemoneyTestCase):
"If-None-Match": build_etag(project.id, "2023-07-26T13:00:00"), "If-None-Match": build_etag(project.id, "2023-07-26T13:00:00"),
}, },
) )
assert resp.status_code == 304 assert resp.status_code == 200
# Add bill # Add bill
with fake_time("2023-07-27 13:00:00"):
self.login("raclette") self.login("raclette")
resp = self.client.post( resp = self.client.post(
"/raclette/add", "/raclette/add",
@ -2143,20 +2097,19 @@ class TestBudget(IhatemoneyTestCase):
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "The bill has been added" in resp.data.decode() assert "The bill has been added" in resp.data.decode()
etag = resp.headers.get("ETag")
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
headers={ headers={"If-None-Match": etag},
"If-None-Match": build_etag(project.id, "2023-07-27T12:00:00"),
},
) )
assert resp.headers.get("ETag") == build_etag(project.id, "2023-07-27T13:00:00")
assert resp.status_code == 200 assert resp.status_code == 200
new_etag = resp.headers.get("ETag")
resp = self.client.get( resp = self.client.get(
f"/raclette/feed/{token}.xml", f"/raclette/feed/{token}.xml",
headers={ headers={
"If-None-Match": build_etag(project.id, "2023-07-27T13:00:00"), "If-None-Match": new_etag,
}, },
) )
assert resp.status_code == 304 assert resp.status_code == 304

View file

@ -3,13 +3,17 @@ import smtplib
import socket import socket
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import orm from sqlalchemy import orm
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from ihatemoney import models from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.manage import delete_project, generate_config, password_hash from ihatemoney.manage import (
delete_project,
generate_config,
get_project_count,
password_hash,
)
from ihatemoney.run import load_configuration from ihatemoney.run import load_configuration
from ihatemoney.tests.common.ihatemoney_testcase import BaseTestCase, IhatemoneyTestCase from ihatemoney.tests.common.ihatemoney_testcase import BaseTestCase, IhatemoneyTestCase
@ -229,6 +233,65 @@ class TestModels(IhatemoneyTestCase):
pay_each_expected = 10 / 3 pay_each_expected = 10 / 3
assert bill.pay_each() == pay_each_expected assert bill.pay_each() == pay_each_expected
def test_demo_project_count(self):
"""Test command the get-project-count"""
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2})
self.client.post("/raclette/members/add", data={"name": "fred"})
self.client.post("/raclette/members/add", data={"name": "tata"})
self.client.post("/raclette/members/add", data={"name": "pépé"})
# create bills
self.client.post(
"/raclette/add",
data={
"date": "2011-08-10",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2, 3],
"amount": "10.0",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2011-08-10",
"what": "red wine",
"payer": 2,
"payed_for": [1],
"amount": "20",
},
)
assert self.get_project("raclette").has_bills()
# Now check the different parameters
runner = self.app.test_cli_runner()
result0 = runner.invoke(get_project_count)
assert result0.output.strip() == "Number of projects: 1"
# With more than 1 bill, without printing emails
result1 = runner.invoke(get_project_count, "False 1")
assert result1.output.strip() == "Number of projects: 1"
# With more than 2 bill, without printing emails
result2 = runner.invoke(get_project_count, "False 2")
assert result2.output.strip() == "Number of projects: 0"
# With more than 0 days old
result3 = runner.invoke(get_project_count, "False 0 0")
assert result3.output.strip() == "Number of projects: 0"
result4 = runner.invoke(get_project_count, "False 0 20000")
assert result4.output.strip() == "Number of projects: 1"
# Print emails
result5 = runner.invoke(get_project_count, "True")
assert "raclette@notmyidea.org" in result5.output
class TestEmailFailure(IhatemoneyTestCase): class TestEmailFailure(IhatemoneyTestCase):
def test_creation_email_failure_smtp(self): def test_creation_email_failure_smtp(self):
@ -401,9 +464,7 @@ class TestCurrencyConverter:
def test_failing_remote(self): def test_failing_remote(self):
rates = {} rates = {}
with patch("requests.Response.json", new=lambda _: {}), pytest.warns( with patch("requests.Response.json", new=lambda _: {}):
UserWarning
):
# we need a non-patched converter, but it seems that MagickMock # we need a non-patched converter, but it seems that MagickMock
# is mocking EVERY instance of the class method. Too bad. # is mocking EVERY instance of the class method. Too bad.
rates = CurrencyConverter.get_rates(self.converter) rates = CurrencyConverter.get_rates(self.converter)

View file

@ -84,7 +84,6 @@ def flash_email_error(error_message, category="danger"):
class Redirect303(HTTPException, RoutingException): class Redirect303(HTTPException, RoutingException):
"""Raise if the map requests a redirect. This is for example the case if """Raise if the map requests a redirect. This is for example the case if
`strict_slashes` are activated and an url that requires a trailing slash. `strict_slashes` are activated and an url that requires a trailing slash.
@ -102,7 +101,6 @@ class Redirect303(HTTPException, RoutingException):
class PrefixedWSGI(object): class PrefixedWSGI(object):
""" """
Wrap the application in this middleware and configure the Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind front-end server to add these headers, to let you quietly bind

View file

@ -2,19 +2,22 @@
The blueprint for the web interface. The blueprint for the web interface.
Contains all the interaction logic with the end user (except forms which Contains all the interaction logic with the end user (except forms which
are directly handled in the forms module. are directly handled in the forms module).
Basically, this blueprint takes care of the authentication and provides Basically, this blueprint takes care of the authentication and provides
some shortcuts to make your life better when coding (see `pull_project` some shortcuts to make your life better when coding (see `pull_project`
and `add_project_id` for a quick overview) and `add_project_id` for a quick overview)
""" """
import datetime import datetime
from functools import wraps
import hashlib import hashlib
import json import json
import os import os
from functools import wraps
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
import qrcode
import qrcode.image.svg
from flask import ( from flask import (
Blueprint, Blueprint,
Response, Response,
@ -33,8 +36,6 @@ from flask import (
) )
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_mail import Message from flask_mail import Message
import qrcode
import qrcode.image.svg
from sqlalchemy_continuum import Operation from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
@ -58,7 +59,7 @@ from ihatemoney.forms import (
get_billform_for, get_billform_for,
) )
from ihatemoney.history import get_history, get_history_queries, purge_history from ihatemoney.history import get_history, get_history_queries, purge_history
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, db from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag, db
from ihatemoney.utils import ( from ihatemoney.utils import (
Redirect303, Redirect303,
csv2list_of_dicts, csv2list_of_dicts,
@ -154,7 +155,8 @@ def pull_project(endpoint, values):
project_id = entered_project_id.lower() project_id = entered_project_id.lower()
project = Project.query.get(project_id) project = Project.query.get(project_id)
if not project: if not project:
raise Redirect303(url_for(".create_project", project_id=project_id)) raise Redirect303(
url_for(".create_project", project_id=project_id))
is_admin = session.get("is_admin") is_admin = session.get("is_admin")
is_invitation = endpoint == "main.join_project" is_invitation = endpoint == "main.join_project"
@ -368,7 +370,8 @@ def remind_password():
# send a link to reset the password # send a link to reset the password
remind_message = Message( remind_message = Message(
"password recovery", "password recovery",
body=render_localized_template("password_reminder", project=project), body=render_localized_template(
"password_reminder", project=project),
recipients=[project.contact_email], recipients=[project.contact_email],
) )
success = send_email(remind_message) success = send_email(remind_message)
@ -611,7 +614,8 @@ def invite():
msg = Message( msg = Message(
message_title, message_title,
body=message_body, body=message_body,
recipients=[email.strip() for email in form.emails.data.split(",")], recipients=[email.strip()
for email in form.emails.data.split(",")],
) )
success = send_email(msg) success = send_email(msg)
if success: if success:
@ -632,7 +636,8 @@ def invite():
token=g.project.generate_token(), token=g.project.generate_token(),
_external=True, _external=True,
) )
invite_link = urlunparse(urlparse(invite_link)._replace(scheme="ihatemoney")) invite_link = urlunparse(
urlparse(invite_link)._replace(scheme="ihatemoney"))
qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage) qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage)
qr.add_data(invite_link) qr.add_data(invite_link)
qr.make(fit=True) qr.make(fit=True)
@ -914,7 +919,8 @@ def strip_ip_addresses():
form = DestructiveActionProjectForm(id=g.project.id) form = DestructiveActionProjectForm(id=g.project.id)
if not form.validate(): if not form.validate():
flash( flash(
format_form_errors(form, _("Error deleting recorded IP addresses")), format_form_errors(
form, _("Error deleting recorded IP addresses")),
category="danger", category="danger",
) )
return redirect(url_for(".history")) return redirect(url_for(".history"))
@ -933,18 +939,22 @@ def statistics():
"""Compute what each participant has paid and spent and display it""" """Compute what each participant has paid and spent and display it"""
# Determine range of months between which there are bills # Determine range of months between which there are bills
months = g.project.active_months_range() months = g.project.active_months_range()
tags = Tag.query.filter(Tag.project_id == g.project.id)
return render_template( return render_template(
"statistics.html", "statistics.html",
members_stats=g.project.members_stats, members_stats=g.project.members_stats,
monthly_stats=g.project.monthly_stats, monthly_stats=g.project.monthly_stats,
tags_monthly_stats=g.project.tags_monthly_stats,
months=months, months=months,
tags=tags,
current_view="statistics", current_view="statistics",
) )
def build_etag(project_id, last_modified): def build_etag(project_id, last_modified):
return hashlib.md5( return hashlib.md5(
(current_app.config["SECRET_KEY"] + project_id + last_modified).encode() (current_app.config["SECRET_KEY"] +
project_id + last_modified).encode()
).hexdigest() ).hexdigest()

View file

@ -5,6 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ihatemoney" name = "ihatemoney"
version = "6.2.0.dev0" version = "6.2.0.dev0"
requires-python = ">=3.8"
description = "A simple shared budget manager web application." description = "A simple shared budget manager web application."
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}
@ -15,7 +16,6 @@ keywords = ["web", "budget"]
classifiers = [ classifiers = [
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
@ -30,6 +30,7 @@ dependencies = [
"cachetools>=4.1,<5", "cachetools>=4.1,<5",
"debts>=0.5,<1", "debts>=0.5,<1",
"email_validator>=1.0,<3", "email_validator>=1.0,<3",
"Flask>=2,<4",
"Flask-Babel>=1.0,<4", "Flask-Babel>=1.0,<4",
"Flask-Cors>=3.0.8,<4", "Flask-Cors>=3.0.8,<4",
"Flask-Limiter>=2.6,<3", "Flask-Limiter>=2.6,<3",
@ -39,41 +40,35 @@ dependencies = [
"Flask-SQLAlchemy>=2.4,<3", "Flask-SQLAlchemy>=2.4,<3",
"Flask-Talisman>=0.8,<2", "Flask-Talisman>=0.8,<2",
"Flask-WTF>=0.14.3,<2", "Flask-WTF>=0.14.3,<2",
"WTForms>=2.3.3,<3.2",
"Flask>=2,<3",
"Werkzeug>=2,<3",
"itsdangerous>=2,<3", "itsdangerous>=2,<3",
"Jinja2>=3,<4", "Jinja2>=3,<4",
"python-dateutil",
"qrcode>=7.1,<8", "qrcode>=7.1,<8",
"requests>=2.25,<3", "requests>=2.25,<3",
"SQLAlchemy-Continuum>=1.3.12,<2", "SQLAlchemy>=1.3.0,<1.5",
"SQLAlchemy>=1.3.0,<1.5", # New 1.4 changes API, see #728 "SQLAlchemy-Continuum>=1.3.12,<2", # New 1.4 changes API, see #728
"python-dateutil", "Werkzeug>=2,<3",
] "WTForms>=2.3.3,<3.3",]
[project.optional-dependencies] [project.optional-dependencies]
database = [ database = [
# Python 3.11 support starts in 2.9.2 # Python 3.11 support starts in 2.9.2
"psycopg2-binary>=2.9.2,<3", "psycopg2-binary>=2.9.2,<2.9.9",
"PyMySQL>=0.9,<1.1", "PyMySQL>=0.9,<1.2",
] ]
dev = [ dev = [
"black==23.3.0", "ruff==0.6.8",
"flake8==5.0.4", "flake8==5.0.4",
"isort==5.11.5", "isort==5.11.5",
"vermin==1.5.2", "vermin==1.6.0",
"pytest>=6.2.5", "pytest>=6.2.5",
"pytest-flask>=1.2.0", "pytest-flask>=1.2.0",
"pytest-libfaketime>=0.1.2",
"tox>=3.14.6",
"zest.releaser>=6.20.1", "zest.releaser>=6.20.1",
] ]
doc = [ doc = [
"Sphinx>=7.0.1,<8", "Sphinx>=7.0.1,<9",
"docutils==0.20.1", "docutils==0.20.1",
"myst-parser>=2,<3", "myst-parser>=2,<5",
] ]
[project.urls] [project.urls]
@ -107,3 +102,7 @@ include = [
"README.md", "README.md",
"SECURITY.md", "SECURITY.md",
] ]
[tool.ruff]
exclude = ["ihatemoney/migrations"]

41
tox.ini
View file

@ -1,41 +0,0 @@
[tox]
isolated_build = true
envlist = py312,py311,py310,py39,py38,py37,lint,docs
skip_missing_interpreters = True
[testenv]
passenv = TESTING_SQLALCHEMY_DATABASE_URI
commands =
python --version
py.test --pyargs ihatemoney.tests {posargs}
deps =
-e.[database,dev]
# To be sure we are importing ihatemoney pkg from pip-installed version
changedir = /tmp
[testenv:lint]
commands =
black --check --target-version=py37 .
isort --check --profile black .
flake8 ihatemoney
vermin --no-tips --violations -t=3.7- .
deps =
-e.[dev]
changedir = {toxinidir}
[testenv:docs]
commands =
sphinx-build -a -n -b html -d docs/_build/doctrees docs docs/_build/html
deps =
-e.[doc]
changedir = {toxinidir}
[flake8]
exclude = migrations
max_line_length = 100
extend-ignore =
# See https://github.com/PyCQA/pycodestyle/issues/373
E203,

1809
uv.lock Normal file

File diff suppressed because it is too large Load diff