mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-14 08:21:49 +02:00
Compare commits
14 commits
50bb87334d
...
7bfefe7d00
Author | SHA1 | Date | |
---|---|---|---|
7bfefe7d00 | |||
![]() |
83a60b1289 | ||
![]() |
ce20f9adea | ||
2b21795e94 | |||
![]() |
0b09476865 | ||
![]() |
62fbeee15c | ||
![]() |
56e2ff6900 | ||
![]() |
35ac04be20 | ||
![]() |
a9f211d3f6 | ||
e568bb05cc | |||
86eb9b8662 | |||
6e31a9c8b5 | |||
f968c9870c | |||
14cc9b96d3 |
23 changed files with 2452 additions and 455 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:
|
||||
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
|
4
.github/workflows/dockerhub.yml
vendored
4
.github/workflows/dockerhub.yml
vendored
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
67
Makefile
67
Makefile
|
@ -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}'
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 ###
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -9,7 +9,6 @@ from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
|
|||
|
||||
|
||||
class TestAPI(IhatemoneyTestCase):
|
||||
|
||||
"""Tests the API"""
|
||||
|
||||
def api_create(
|
||||
|
|
|
@ -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,59 +1867,56 @@ 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"})
|
||||
self.client.post("/raclette/members/add", data={"name": "steven"})
|
||||
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"})
|
||||
self.client.post("/raclette/members/add", data={"name": "steven"})
|
||||
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-31",
|
||||
"what": "fromage à raclette",
|
||||
"payer": 1,
|
||||
"payed_for": [1, 2, 3],
|
||||
"amount": "12",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-30",
|
||||
"what": "charcuterie",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "15",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-29",
|
||||
"what": "vin blanc",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "10",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-31",
|
||||
"what": "fromage à raclette",
|
||||
"payer": 1,
|
||||
"payed_for": [1, 2, 3],
|
||||
"amount": "12",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-30",
|
||||
"what": "charcuterie",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "15",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-29",
|
||||
"what": "vin blanc",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "10",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
|
||||
project = self.get_project("raclette")
|
||||
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,190 +1926,151 @@ 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"})
|
||||
self.client.post("/raclette/members/add", data={"name": "steven"})
|
||||
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"})
|
||||
self.client.post("/raclette/members/add", data={"name": "steven"})
|
||||
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-31",
|
||||
"what": "fromage à raclette",
|
||||
"payer": 1,
|
||||
"payed_for": [1, 2, 3],
|
||||
"amount": "12",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-30",
|
||||
"what": "charcuterie",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "15",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-29",
|
||||
"what": "vin blanc",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "10",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-31",
|
||||
"what": "fromage à raclette",
|
||||
"payer": 1,
|
||||
"payed_for": [1, 2, 3],
|
||||
"amount": "12",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-30",
|
||||
"what": "charcuterie",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "15",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-29",
|
||||
"what": "vin blanc",
|
||||
"payer": 2,
|
||||
"payed_for": [1, 2],
|
||||
"amount": "10",
|
||||
"original_currency": "EUR",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
)
|
||||
|
||||
project = self.get_project("raclette")
|
||||
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")
|
||||
token = project.generate_token("feed")
|
||||
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.status_code == 200
|
||||
assert resp.headers.get("Last-Modified") == "Wed, 26 Jul 2023 13:00:00 UTC"
|
||||
resp = self.client.get(f"/raclette/feed/{token}.xml")
|
||||
assert resp.status_code == 200
|
||||
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",
|
||||
data={
|
||||
"date": "2016-12-31",
|
||||
"what": "fromage à raclette",
|
||||
"payer": 1,
|
||||
"payed_for": [1],
|
||||
"amount": "12",
|
||||
"original_currency": "XXX",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "The bill has been added" in resp.data.decode()
|
||||
self.login("raclette")
|
||||
resp = self.client.post(
|
||||
"/raclette/add",
|
||||
data={
|
||||
"date": "2016-12-31",
|
||||
"what": "fromage à raclette",
|
||||
"payer": 1,
|
||||
"payed_for": [1],
|
||||
"amount": "12",
|
||||
"original_currency": "XXX",
|
||||
"bill_type": "Expense",
|
||||
},
|
||||
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-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")
|
||||
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"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = self.client.get(f"/raclette/feed/{token}.xml")
|
||||
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,40 +2078,38 @@ class TestBudget(IhatemoneyTestCase):
|
|||
"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
|
||||
|
||||
# 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(
|
||||
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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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,50 +30,45 @@ 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",
|
||||
"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-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
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