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:
push:
@ -8,26 +8,20 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
- name: Install uv and set the python version
uses: astral-sh/setup-uv@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: Run Lint
run: tox -e lint
run: make lint
test:
# Dependency on linting to avoid running our expensive matrix test for nothing
needs: lint
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
# Use postgresql and MariaDB versions of Debian bookworm
services:
postgres:
@ -53,10 +47,11 @@ jobs:
strategy:
fail-fast: false
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]
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:
- python-version: 3.9
dependencies: normal
@ -71,9 +66,6 @@ jobs:
dependencies: normal
database: mariadb
# Try a few variants with the minimal versions supported
- python-version: 3.7
dependencies: minimal
database: sqlite
- python-version: 3.9
dependencies: minimal
database: sqlite
@ -95,32 +87,36 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: '**/pyproject.toml'
- 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'
- name: Install dependencies
run: |
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'
- name: Run tests
run: uv run --extra dev --extra database pytest .
env:
TESTING_SQLALCHEMY_DATABASE_URI: 'sqlite:///budget.db'
- name: Run Tox with postgresql
run: tox -e py
if: matrix.database == 'postgresql'
env:
TESTING_SQLALCHEMY_DATABASE_URI: 'postgresql+psycopg2://postgres:ihatemoney@localhost:5432/ihatemoney_ci'
- name: Run Tox with mariadb
run: tox -e py
if: matrix.database == 'mariadb'
env:
TESTING_SQLALCHEMY_DATABASE_URI: 'mysql+pymysql://root:ihatemoney@localhost:3306/ihatemoney_ci'
# Setup the DATABASE_URI depending on the matrix we are using.
TESTING_SQLALCHEMY_DATABASE_URI: ${{
matrix.database == 'sqlite'
&& 'sqlite:///budget.db'
|| matrix.database == 'postgresql'
&& 'postgresql+psycopg2://postgres:ihatemoney@localhost:5432/ihatemoney_ci'
|| 'mysql+pymysql://root:ihatemoney@localhost:3306/ihatemoney_ci'
}}
docs:
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:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v2
@ -19,7 +19,7 @@ jobs:
run: docker compose -f docker-compose.test.yml run sut
build_upload:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
needs: test
if: github.event_name != 'pull_request'
steps:

View file

@ -5,7 +5,10 @@ This document describes changes between each past release.
## 6.2.0 (unreleased)
- 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)

View file

@ -1,60 +1,40 @@
VIRTUALENV = python3 -m venv
SPHINX_BUILDDIR = docs/_build
VIRTUALENV = uv 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
INSTALL_STAMP = $(VENV)/.install.stamp
TEMPDIR := $(shell mktemp -d)
ZOPFLIPNG := zopflipng
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
virtualenv: $(PYTHON)
$(PYTHON):
$(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
serve: install build-translations ## Run the ihatemoney server
serve: build-translations ## Run the ihatemoney server
@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
test: install-dev ## Run the tests
$(VENV)/bin/tox
test:
uv run --extra dev --extra database pytest .
.PHONY: black
black: install-dev ## Run the tests
$(VENV)/bin/black --target-version=py37 .
.PHONY: lint
lint:
uv tool run ruff check .
uv tool run vermin --no-tips --violations -t=3.8- .
.PHONY: isort
isort: install-dev ## Run the tests
$(VENV)/bin/isort .
.PHONY: format
format:
uv tool run ruff format .
.PHONY: release
release: install-dev ## Release a new version (see https://ihatemoney.readthedocs.io/en/latest/contributing.html#how-to-release)
$(VENV)/bin/fullrelease
release: # Release a new version (see https://ihatemoney.readthedocs.io/en/latest/contributing.html#how-to-release)
uv run --extra dev fullreleas
.PHONY: compress-showcase
compress-showcase:
@ -72,27 +52,30 @@ compress-assets: compress-showcase ## Compress static assets
.PHONY: build-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
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
$(VENV)/bin/pybabel update -i ihatemoney/messages.pot -d ihatemoney/translations/
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
uv run --extra dev pybabel update -i ihatemoney/messages.pot -d ihatemoney/translations/
.PHONY: create-database-revision
create-database-revision: ## Create a new database revision
@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
create-empty-database-revision: ## Create an empty database revision
@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
clean: ## Destroy the virtual environment
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
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}'

View file

@ -22,7 +22,7 @@ highly encouraged to do so.
## 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),
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.
To do so, just run:
make black isort
make lint
You can also integrate them with your dev environment (as a
*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:
- **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 (>=
10.3.2).
- **Virtual environment** (recommended): [python3-venv]{.title-ref}

View file

@ -1,6 +1,6 @@
from datetime import datetime
import decimal
from re import match
from datetime import datetime
from re import findall, match
from types import SimpleNamespace
import email_validator
@ -39,7 +39,7 @@ from wtforms.validators import (
)
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 (
em_surround,
eval_arithmetic_expression,
@ -90,7 +90,6 @@ def get_billform_for(project, set_default=True, **kwargs):
class CommaDecimalField(DecimalField):
"""A class to deal with comma in Decimal Field"""
def process_formdata(self, value):
@ -135,7 +134,8 @@ class EditProjectForm(FlaskForm):
_("New private code"),
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"))
ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter()
@ -228,7 +228,8 @@ class ImportProjectForm(FlaskForm):
"File",
validators=[
FileRequired(),
FileAllowed(["json", "JSON", "csv", "CSV"], "Incorrect file format"),
FileAllowed(["json", "JSON", "csv", "CSV"],
"Incorrect file format"),
],
description=_("Compatible with Cospend"),
)
@ -349,9 +350,11 @@ class ResetPasswordForm(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()])
payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int)
payer = SelectField(_("Who paid?"), validators=[
DataRequired()], coerce=int)
amount = CalculatorStringField(_("How much?"), validators=[DataRequired()])
currency_helper = CurrencyConverter()
original_currency = SelectField(_("Currency"), validators=[DataRequired()])
@ -373,8 +376,30 @@ class BillForm(FlaskForm):
submit = SubmitField(_("Submit"))
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):
return Bill(
"""This is triggered on bill creation.
"""
what, hashtags = self.parse_hashtags(project, self.what.data)
bill = Bill(
amount=float(self.amount.data),
date=self.date.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),
payer_id=self.payer.data,
project_default_currency=project.default_currency,
what=self.what.data,
what=what,
bill_type=self.bill_type.data,
)
bill.set_tags(hashtags, project)
return bill
def save(self, bill, project):
what, hashtags = self.parse_hashtags(project, self.what.data)
bill.payer_id = self.payer.data
bill.amount = self.amount.data
bill.what = self.what.data
bill.what = what
bill.bill_type = BillType(self.bill_type.data)
bill.external_link = self.external_link.data
bill.date = self.date.data
@ -398,19 +426,22 @@ class BillForm(FlaskForm):
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
bill.set_tags(hashtags, project)
return bill
def fill(self, bill, project):
self.payer.data = bill.payer_id
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.external_link.data = bill.external_link
self.original_currency.data = bill.original_currency
self.date.data = bill.date
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 = _(
"Project default: %(currency)s",
currency=render_localized_currency(
@ -439,10 +470,13 @@ class BillForm(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 = CommaDecimalField(_("Weight"), default=1, validators=weight_validators)
weight_validators = [NumberRange(
min=0.1, message=_("Weights should be positive"))]
weight = CommaDecimalField(
_("Weight"), default=1, validators=weight_validators)
submit = SubmitField(_("Add"))
def __init__(self, project, edit=False, *args, **kwargs):
@ -461,7 +495,8 @@ class MemberForm(FlaskForm):
Person.activated,
).all()
): # NOQA
raise ValidationError(_("This project already have this participant"))
raise ValidationError(
_("This project already have this participant"))
def save(self, project, person):
# if the user is already bound to the project, just reactivate him

View file

@ -4,6 +4,7 @@ import getpass
import os
import random
import sys
import datetime
import click
from flask.cli import FlaskGroup
@ -93,5 +94,31 @@ def delete_project(project_name):
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__":
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
from enum import Enum
import itertools
from collections import defaultdict
from enum import Enum
import sqlalchemy
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from debts import settle
@ -14,7 +15,6 @@ from itsdangerous import (
URLSafeSerializer,
URLSafeTimedSerializer,
)
import sqlalchemy
from sqlalchemy import orm
from sqlalchemy.sql import func
from sqlalchemy_continuum import make_versioned, version_class
@ -125,7 +125,8 @@ class Project(db.Model):
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():
total_weight = sum(ower.weight for ower in bill.owers)
@ -181,11 +182,28 @@ class Project(db.Model):
:rtype dict:
"""
monthly = defaultdict(lambda: defaultdict(float))
for bill in self.get_bills_unordered().all():
if bill.bill_type == BillType.EXPENSE:
monthly[bill.date.year][bill.date.month] += bill.converted_amount
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
def uses_weights(self):
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
)
# 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
months = itertools.takewhile(
lambda x: x > oldest_date - relativedelta(months=1), all_months
@ -497,7 +516,8 @@ class Project(db.Model):
)
loads_kwargs["max_age"] = max_age
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 ""
serializer = URLSafeSerializer(
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
billowers = db.Table(
"billowers",
db.Column("bill_id", db.Integer, db.ForeignKey("bill.id"), primary_key=True),
db.Column("person_id", db.Integer, db.ForeignKey("person.id"), primary_key=True),
db.Column("bill_id", db.Integer, db.ForeignKey(
"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,
)
@ -688,6 +750,7 @@ class Bill(db.Model):
what = db.Column(db.UnicodeText)
bill_type = db.Column(db.Enum(BillType))
external_link = db.Column(db.UnicodeText)
tags = db.relationship(Tag, secondary=billtags)
original_currency = db.Column(db.String(3))
converted_amount = db.Column(db.Float)
@ -752,15 +815,24 @@ class Bill(db.Model):
else:
return 0
def __str__(self):
return self.what
def pay_each(self):
"""Warning: this is slow, if you need to compute this for many bills, do
it differently (see balance_full function)
"""
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):
return (
f"<Bill of {self.amount} from {self.payer} for "
@ -790,3 +862,4 @@ sqlalchemy.orm.configure_mappers()
PersonVersion = version_class(Person)
ProjectVersion = version_class(Project)
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:
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"]
app.config["MAIL_DEFAULT_SENDER"] = f"{name} <{address}>"
warnings.warn(

View file

@ -116,12 +116,18 @@
{% if bills.total > 0 %}
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
<thead>
<tr><th>{{ _("When?") }}
</th><th>{{ _("Who paid?") }}
</th><th>{{ _("For what?") }}
</th><th>{{ _("For whom?") }}
</th><th>{{ _("How much?") }}
</th><th>{{ _("Actions") }}</th></tr>
<tr><th>{{ _("When?") }}</th>
<th>{{ _("Who paid?") }}</th>
<th>{{ _("For what?") }}</th>
<th>{{ _("For whom?") }}</th>
<th>{{ _("How much?") }}</th>
<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>
<tbody>
{% for (weights, bill) in bills.items %}
@ -147,6 +153,11 @@
{{ weighted_bill_amount(bill, weights) }}
</span>
</td>
<td>
{% for tag in bill.tags %}
#{{ tag.name }}
{% endfor %}
</td>
<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>
<form class="delete-bill" action="{{ url_for(".delete_bill", bill_id=bill.id) }}" method="POST">

View file

@ -23,12 +23,28 @@
</table>
<h2>{{ _("Expenses by Month") }}</h2>
<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>
{% for month in months %}
<tr>
<td>{{ month|dateformat("MMMM yyyy") }}</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>
{% endfor %}
</tbody>

View file

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

View file

@ -1,10 +1,9 @@
from collections import defaultdict
import datetime
from datetime import datetime, timedelta, date
import re
from urllib.parse import unquote, urlparse, urlunparse
from flask import session, url_for
from libfaketime import fake_time
import pytest
from werkzeug.security import check_password_hash
@ -1030,9 +1029,7 @@ class TestBudget(IhatemoneyTestCase):
assert """<thead>
<tr>
<th>Project</th>
<th>Number of participants</th>""" in resp.data.decode(
"utf-8"
)
<th>Number of participants</th>""" in resp.data.decode("utf-8")
def test_dashboard_project_deletion(self):
self.post_project("raclette")
@ -1146,7 +1143,7 @@ class TestBudget(IhatemoneyTestCase):
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
august = datetime.date(year=2011, month=8, day=1)
august = date(year=2011, month=8, day=1)
assert project.active_months_range() == [august]
assert dict(project.monthly_stats[2011]) == {8: 40.0}
@ -1163,11 +1160,11 @@ class TestBudget(IhatemoneyTestCase):
},
)
months = [
datetime.date(year=2011, month=12, day=1),
datetime.date(year=2011, month=11, day=1),
datetime.date(year=2011, month=10, day=1),
datetime.date(year=2011, month=9, day=1),
datetime.date(year=2011, month=8, day=1),
date(year=2011, month=12, day=1),
date(year=2011, month=11, day=1),
date(year=2011, month=10, day=1),
date(year=2011, month=9, day=1),
date(year=2011, month=8, day=1),
]
amounts_2011 = {
12: 30.0,
@ -1220,7 +1217,7 @@ class TestBudget(IhatemoneyTestCase):
"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
assert project.active_months_range() == months
assert dict(project.monthly_stats[2011]) == amounts_2011
@ -1237,7 +1234,7 @@ class TestBudget(IhatemoneyTestCase):
"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}
assert project.active_months_range() == months
assert dict(project.monthly_stats[2011]) == amounts_2011
@ -1870,7 +1867,6 @@ class TestBudget(IhatemoneyTestCase):
"""
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.client.post("/raclette/members/add", data={"name": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"})
@ -1917,12 +1913,10 @@ class TestBudget(IhatemoneyTestCase):
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
expected_rss_content = f"""<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:atom="http://www.w3.org/2005/Atom"
>
<channel>
content = resp.data.decode()
assert (
f"""<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" />
@ -1932,32 +1926,18 @@ class TestBudget(IhatemoneyTestCase):
<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
"""
in content
)
assert """<title>charcuterie - €15.00</title>""" in content
assert """<title>vin blanc - €10.00</title>""" in content
def test_rss_feed_history_disabled(self):
"""
Tests that RSS feeds is correctly rendered even if the project
history is disabled.
"""
with fake_time("2023-07-25 12:00:00"):
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": "peter"})
@ -2004,44 +1984,12 @@ class TestBudget(IhatemoneyTestCase):
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
expected_rss_content = f"""<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:dc="http://purl.org/dc/elements/1.1/"
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
content = resp.data.decode()
assert """<title>charcuterie - €15.00</title>""" in content
assert """<title>vin blanc - €10.00</title>""" in content
def test_rss_if_modified_since_header(self):
# Project creation
with fake_time("2023-07-26 13:00:00"):
self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"})
project = self.get_project("raclette")
@ -2049,22 +1997,33 @@ class TestBudget(IhatemoneyTestCase):
resp = self.client.get(f"/raclette/feed/{token}.xml")
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(
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
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(
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
# Add bill
with fake_time("2023-07-27 13:00:00"):
self.login("raclette")
resp = self.client.post(
"/raclette/add",
@ -2084,38 +2043,34 @@ class TestBudget(IhatemoneyTestCase):
resp = self.client.get(
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
resp = self.client.get(
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
def test_rss_etag_headers(self):
# Project creation
with fake_time("2023-07-26 13:00:00"):
self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"})
project = self.get_project("raclette")
token = project.generate_token("feed")
resp = self.client.get(f"/raclette/feed/{token}.xml")
assert resp.headers.get("ETag") == build_etag(
project.id, "2023-07-26T13:00:00"
)
etag = resp.headers.get("ETag")
assert resp.status_code == 200
resp = self.client.get(
f"/raclette/feed/{token}.xml",
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(
f"/raclette/feed/{token}.xml",
@ -2123,10 +2078,9 @@ class TestBudget(IhatemoneyTestCase):
"If-None-Match": build_etag(project.id, "2023-07-26T13:00:00"),
},
)
assert resp.status_code == 304
assert resp.status_code == 200
# Add bill
with fake_time("2023-07-27 13:00:00"):
self.login("raclette")
resp = self.client.post(
"/raclette/add",
@ -2143,20 +2097,19 @@ class TestBudget(IhatemoneyTestCase):
)
assert resp.status_code == 200
assert "The bill has been added" in resp.data.decode()
etag = resp.headers.get("ETag")
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={
"If-None-Match": build_etag(project.id, "2023-07-27T12:00:00"),
},
headers={"If-None-Match": etag},
)
assert resp.headers.get("ETag") == build_etag(project.id, "2023-07-27T13:00:00")
assert resp.status_code == 200
new_etag = resp.headers.get("ETag")
resp = self.client.get(
f"/raclette/feed/{token}.xml",
headers={
"If-None-Match": build_etag(project.id, "2023-07-27T13:00:00"),
"If-None-Match": new_etag,
},
)
assert resp.status_code == 304

View file

@ -3,13 +3,17 @@ import smtplib
import socket
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import orm
from werkzeug.security import check_password_hash
from ihatemoney import models
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.tests.common.ihatemoney_testcase import BaseTestCase, IhatemoneyTestCase
@ -229,6 +233,65 @@ class TestModels(IhatemoneyTestCase):
pay_each_expected = 10 / 3
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):
def test_creation_email_failure_smtp(self):
@ -401,9 +464,7 @@ class TestCurrencyConverter:
def test_failing_remote(self):
rates = {}
with patch("requests.Response.json", new=lambda _: {}), pytest.warns(
UserWarning
):
with patch("requests.Response.json", new=lambda _: {}):
# we need a non-patched converter, but it seems that MagickMock
# is mocking EVERY instance of the class method. Too bad.
rates = CurrencyConverter.get_rates(self.converter)

View file

@ -84,7 +84,6 @@ def flash_email_error(error_message, category="danger"):
class Redirect303(HTTPException, RoutingException):
"""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.
@ -102,7 +101,6 @@ class Redirect303(HTTPException, RoutingException):
class PrefixedWSGI(object):
"""
Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind

View file

@ -2,19 +2,22 @@
The blueprint for the web interface.
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
some shortcuts to make your life better when coding (see `pull_project`
and `add_project_id` for a quick overview)
"""
import datetime
from functools import wraps
import hashlib
import json
import os
from functools import wraps
from urllib.parse import urlparse, urlunparse
import qrcode
import qrcode.image.svg
from flask import (
Blueprint,
Response,
@ -33,8 +36,6 @@ from flask import (
)
from flask_babel import gettext as _
from flask_mail import Message
import qrcode
import qrcode.image.svg
from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash
@ -58,7 +59,7 @@ from ihatemoney.forms import (
get_billform_for,
)
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 (
Redirect303,
csv2list_of_dicts,
@ -154,7 +155,8 @@ def pull_project(endpoint, values):
project_id = entered_project_id.lower()
project = Project.query.get(project_id)
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_invitation = endpoint == "main.join_project"
@ -368,7 +370,8 @@ def remind_password():
# send a link to reset the password
remind_message = Message(
"password recovery",
body=render_localized_template("password_reminder", project=project),
body=render_localized_template(
"password_reminder", project=project),
recipients=[project.contact_email],
)
success = send_email(remind_message)
@ -611,7 +614,8 @@ def invite():
msg = Message(
message_title,
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)
if success:
@ -632,7 +636,8 @@ def invite():
token=g.project.generate_token(),
_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.add_data(invite_link)
qr.make(fit=True)
@ -914,7 +919,8 @@ def strip_ip_addresses():
form = DestructiveActionProjectForm(id=g.project.id)
if not form.validate():
flash(
format_form_errors(form, _("Error deleting recorded IP addresses")),
format_form_errors(
form, _("Error deleting recorded IP addresses")),
category="danger",
)
return redirect(url_for(".history"))
@ -933,18 +939,22 @@ def statistics():
"""Compute what each participant has paid and spent and display it"""
# Determine range of months between which there are bills
months = g.project.active_months_range()
tags = Tag.query.filter(Tag.project_id == g.project.id)
return render_template(
"statistics.html",
members_stats=g.project.members_stats,
monthly_stats=g.project.monthly_stats,
tags_monthly_stats=g.project.tags_monthly_stats,
months=months,
tags=tags,
current_view="statistics",
)
def build_etag(project_id, last_modified):
return hashlib.md5(
(current_app.config["SECRET_KEY"] + project_id + last_modified).encode()
(current_app.config["SECRET_KEY"] +
project_id + last_modified).encode()
).hexdigest()

View file

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