mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-14 16:31:49 +02:00
Compare commits
15 commits
6c782a3623
...
ecd9fb6da8
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ecd9fb6da8 | ||
![]() |
83a60b1289 | ||
![]() |
ce20f9adea | ||
2b21795e94 | |||
![]() |
0b09476865 | ||
![]() |
62fbeee15c | ||
![]() |
56e2ff6900 | ||
![]() |
35ac04be20 | ||
![]() |
a9f211d3f6 | ||
e568bb05cc | |||
86eb9b8662 | |||
6e31a9c8b5 | |||
![]() |
72bc34e4af | ||
![]() |
f9e1e9241c | ||
![]() |
12c77b4850 |
20 changed files with 2176 additions and 415 deletions
26
.github/workflows/check-doc.yml
vendored
26
.github/workflows/check-doc.yml
vendored
|
@ -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
|
|
|
@ -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
|
4
.github/workflows/dockerhub.yml
vendored
4
.github/workflows/dockerhub.yml
vendored
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
67
Makefile
67
Makefile
|
@ -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}'
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,59 +1867,56 @@ 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"})
|
self.client.post("/raclette/members/add", data={"name": "steven"})
|
||||||
self.client.post("/raclette/members/add", data={"name": "steven"})
|
|
||||||
|
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/raclette/add",
|
"/raclette/add",
|
||||||
data={
|
data={
|
||||||
"date": "2016-12-31",
|
"date": "2016-12-31",
|
||||||
"what": "fromage à raclette",
|
"what": "fromage à raclette",
|
||||||
"payer": 1,
|
"payer": 1,
|
||||||
"payed_for": [1, 2, 3],
|
"payed_for": [1, 2, 3],
|
||||||
"amount": "12",
|
"amount": "12",
|
||||||
"original_currency": "EUR",
|
"original_currency": "EUR",
|
||||||
"bill_type": "Expense",
|
"bill_type": "Expense",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/raclette/add",
|
"/raclette/add",
|
||||||
data={
|
data={
|
||||||
"date": "2016-12-30",
|
"date": "2016-12-30",
|
||||||
"what": "charcuterie",
|
"what": "charcuterie",
|
||||||
"payer": 2,
|
"payer": 2,
|
||||||
"payed_for": [1, 2],
|
"payed_for": [1, 2],
|
||||||
"amount": "15",
|
"amount": "15",
|
||||||
"original_currency": "EUR",
|
"original_currency": "EUR",
|
||||||
"bill_type": "Expense",
|
"bill_type": "Expense",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/raclette/add",
|
"/raclette/add",
|
||||||
data={
|
data={
|
||||||
"date": "2016-12-29",
|
"date": "2016-12-29",
|
||||||
"what": "vin blanc",
|
"what": "vin blanc",
|
||||||
"payer": 2,
|
"payer": 2,
|
||||||
"payed_for": [1, 2],
|
"payed_for": [1, 2],
|
||||||
"amount": "10",
|
"amount": "10",
|
||||||
"original_currency": "EUR",
|
"original_currency": "EUR",
|
||||||
"bill_type": "Expense",
|
"bill_type": "Expense",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
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,190 +1926,151 @@ 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"})
|
self.client.post("/raclette/members/add", data={"name": "steven"})
|
||||||
self.client.post("/raclette/members/add", data={"name": "steven"})
|
|
||||||
|
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/raclette/add",
|
"/raclette/add",
|
||||||
data={
|
data={
|
||||||
"date": "2016-12-31",
|
"date": "2016-12-31",
|
||||||
"what": "fromage à raclette",
|
"what": "fromage à raclette",
|
||||||
"payer": 1,
|
"payer": 1,
|
||||||
"payed_for": [1, 2, 3],
|
"payed_for": [1, 2, 3],
|
||||||
"amount": "12",
|
"amount": "12",
|
||||||
"original_currency": "EUR",
|
"original_currency": "EUR",
|
||||||
"bill_type": "Expense",
|
"bill_type": "Expense",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/raclette/add",
|
"/raclette/add",
|
||||||
data={
|
data={
|
||||||
"date": "2016-12-30",
|
"date": "2016-12-30",
|
||||||
"what": "charcuterie",
|
"what": "charcuterie",
|
||||||
"payer": 2,
|
"payer": 2,
|
||||||
"payed_for": [1, 2],
|
"payed_for": [1, 2],
|
||||||
"amount": "15",
|
"amount": "15",
|
||||||
"original_currency": "EUR",
|
"original_currency": "EUR",
|
||||||
"bill_type": "Expense",
|
"bill_type": "Expense",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/raclette/add",
|
"/raclette/add",
|
||||||
data={
|
data={
|
||||||
"date": "2016-12-29",
|
"date": "2016-12-29",
|
||||||
"what": "vin blanc",
|
"what": "vin blanc",
|
||||||
"payer": 2,
|
"payer": 2,
|
||||||
"payed_for": [1, 2],
|
"payed_for": [1, 2],
|
||||||
"amount": "10",
|
"amount": "10",
|
||||||
"original_currency": "EUR",
|
"original_currency": "EUR",
|
||||||
"bill_type": "Expense",
|
"bill_type": "Expense",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
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")
|
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.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",
|
data={
|
||||||
data={
|
"date": "2016-12-31",
|
||||||
"date": "2016-12-31",
|
"what": "fromage à raclette",
|
||||||
"what": "fromage à raclette",
|
"payer": 1,
|
||||||
"payer": 1,
|
"payed_for": [1],
|
||||||
"payed_for": [1],
|
"amount": "12",
|
||||||
"amount": "12",
|
"original_currency": "XXX",
|
||||||
"original_currency": "XXX",
|
"bill_type": "Expense",
|
||||||
"bill_type": "Expense",
|
},
|
||||||
},
|
follow_redirects=True,
|
||||||
follow_redirects=True,
|
)
|
||||||
)
|
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()
|
|
||||||
|
|
||||||
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,40 +2078,38 @@ 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
|
|
||||||
|
|
||||||
# Add bill
|
|
||||||
with fake_time("2023-07-27 13:00:00"):
|
|
||||||
self.login("raclette")
|
|
||||||
resp = self.client.post(
|
|
||||||
"/raclette/add",
|
|
||||||
data={
|
|
||||||
"date": "2016-12-31",
|
|
||||||
"what": "fromage à raclette",
|
|
||||||
"payer": 1,
|
|
||||||
"payed_for": [1],
|
|
||||||
"amount": "12",
|
|
||||||
"bill_type": "Expense",
|
|
||||||
"original_currency": "XXX",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert "The bill has been added" in resp.data.decode()
|
|
||||||
|
|
||||||
resp = self.client.get(
|
|
||||||
f"/raclette/feed/{token}.xml",
|
|
||||||
headers={
|
|
||||||
"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
|
||||||
|
|
||||||
|
# Add bill
|
||||||
|
self.login("raclette")
|
||||||
|
resp = self.client.post(
|
||||||
|
"/raclette/add",
|
||||||
|
data={
|
||||||
|
"date": "2016-12-31",
|
||||||
|
"what": "fromage à raclette",
|
||||||
|
"payer": 1,
|
||||||
|
"payed_for": [1],
|
||||||
|
"amount": "12",
|
||||||
|
"bill_type": "Expense",
|
||||||
|
"original_currency": "XXX",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
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": etag},
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
|
@ -442,7 +442,7 @@ class TestExport(IhatemoneyTestCase):
|
||||||
'2016-12-31,red wine,Expense,XXX,200.0,jeanne,1.0,"zorglub, tata"',
|
'2016-12-31,red wine,Expense,XXX,200.0,jeanne,1.0,"zorglub, tata"',
|
||||||
'2016-12-31,à raclette,Expense,10.0,XXX,zorglub,2.0,"zorglub, jeanne, tata, pépé"',
|
'2016-12-31,à raclette,Expense,10.0,XXX,zorglub,2.0,"zorglub, jeanne, tata, pépé"',
|
||||||
]
|
]
|
||||||
received_lines = resp.data.decode("utf-8").split("\n")
|
received_lines = resp.data.decode("utf-8-sig").split("\n")
|
||||||
|
|
||||||
for i, line in enumerate(expected):
|
for i, line in enumerate(expected):
|
||||||
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
|
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
|
||||||
|
@ -476,7 +476,7 @@ class TestExport(IhatemoneyTestCase):
|
||||||
"55.34,XXX,jeanne,tata",
|
"55.34,XXX,jeanne,tata",
|
||||||
"127.33,XXX,jeanne,zorglub",
|
"127.33,XXX,jeanne,zorglub",
|
||||||
]
|
]
|
||||||
received_lines = resp.data.decode("utf-8").split("\n")
|
received_lines = resp.data.decode("utf-8-sig").split("\n")
|
||||||
|
|
||||||
for i, line in enumerate(expected):
|
for i, line in enumerate(expected):
|
||||||
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
|
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
|
||||||
|
@ -682,7 +682,7 @@ class TestExport(IhatemoneyTestCase):
|
||||||
"date,what,bill_type,amount,currency,payer_name,payer_weight,owers",
|
"date,what,bill_type,amount,currency,payer_name,payer_weight,owers",
|
||||||
"2016-12-31,'=COS(36),Expense,10.0,EUR,zorglub,1.0,zorglub",
|
"2016-12-31,'=COS(36),Expense,10.0,EUR,zorglub,1.0,zorglub",
|
||||||
]
|
]
|
||||||
received_lines = resp.data.decode("utf-8").split("\n")
|
received_lines = resp.data.decode("utf-8-sig").split("\n")
|
||||||
|
|
||||||
for i, line in enumerate(expected):
|
for i, line in enumerate(expected):
|
||||||
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
|
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
@ -196,7 +194,7 @@ def list_of_dicts2csv(dict_to_convert):
|
||||||
writer = csv.writer(csv_file)
|
writer = csv.writer(csv_file)
|
||||||
writer.writerows(csv_data)
|
writer.writerows(csv_data)
|
||||||
csv_file.seek(0)
|
csv_file.seek(0)
|
||||||
csv_file = BytesIO(csv_file.getvalue().encode("utf-8"))
|
csv_file = BytesIO(csv_file.getvalue().encode("utf-8-sig"))
|
||||||
return csv_file
|
return csv_file
|
||||||
|
|
||||||
|
|
||||||
|
@ -204,7 +202,7 @@ def csv2list_of_dicts(csv_to_convert):
|
||||||
"""Take a csv in-memory file and turns it into
|
"""Take a csv in-memory file and turns it into
|
||||||
a list of dictionnaries
|
a list of dictionnaries
|
||||||
"""
|
"""
|
||||||
csv_file = TextIOWrapper(csv_to_convert, encoding="utf-8")
|
csv_file = TextIOWrapper(csv_to_convert, encoding="utf-8-sig")
|
||||||
reader = csv.DictReader(csv_file)
|
reader = csv.DictReader(csv_file)
|
||||||
result = []
|
result = []
|
||||||
for r in reader:
|
for r in reader:
|
||||||
|
|
|
@ -8,6 +8,7 @@ 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
|
from functools import wraps
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
|
@ -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,50 +30,45 @@ 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",
|
||||||
"Flask-Mail>=0.9.1,<1",
|
"Flask-Mail>=0.9.1,<1",
|
||||||
"Flask-Migrate>=2.5.3,<5", # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b)
|
"Flask-Migrate>=2.5.3,<5", # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b)
|
||||||
"Flask-RESTful>=0.3.9,<1",
|
"Flask-RESTful>=0.3.9,<1",
|
||||||
"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
41
tox.ini
|
@ -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,
|
|
Loading…
Reference in a new issue