Compare commits

...

4 commits

Author SHA1 Message Date
488e4e2fc9
Merge f968c9870c into 19ecdb5052 2025-01-05 22:11:46 +01:00
zorun
19ecdb5052
Change settle endpoint to use POST instead of GET (#1303)
Some checks failed
CI / lint (push) Has been cancelled
CI / docs (push) Has been cancelled
Docker build / test (push) Has been cancelled
CI / test (mariadb, minimal, 3.11) (push) Has been cancelled
CI / test (mariadb, normal, 3.11) (push) Has been cancelled
CI / test (mariadb, normal, 3.9) (push) Has been cancelled
CI / test (postgresql, minimal, 3.11) (push) Has been cancelled
CI / test (postgresql, normal, 3.11) (push) Has been cancelled
CI / test (postgresql, normal, 3.9) (push) Has been cancelled
CI / test (sqlite, minimal, 3.10) (push) Has been cancelled
CI / test (sqlite, minimal, 3.11) (push) Has been cancelled
CI / test (sqlite, minimal, 3.12) (push) Has been cancelled
CI / test (sqlite, minimal, 3.9) (push) Has been cancelled
CI / test (sqlite, normal, 3.10) (push) Has been cancelled
CI / test (sqlite, normal, 3.11) (push) Has been cancelled
CI / test (sqlite, normal, 3.12) (push) Has been cancelled
CI / test (sqlite, normal, 3.9) (push) Has been cancelled
Docker build / build_upload (push) Has been cancelled
Co-authored-by: Baptiste Jonglez <git@bitsofnetworks.org>
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-01-05 22:11:41 +01:00
f968c9870c
FIXUP: some more work 2024-09-29 16:16:41 +02:00
14cc9b96d3
feat(tags): Add tags on bills
Tags can now be added in the description of a bill, using a hashtag
symbol (`#tagname`).

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

Statistics have been updated to include tags per month.

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

View file

@ -1,6 +1,6 @@
from datetime import datetime
import decimal import decimal
from re import match from datetime import datetime
from re import findall, match
from types import SimpleNamespace from types import SimpleNamespace
import email_validator import email_validator
@ -14,6 +14,8 @@ from wtforms.fields import (
BooleanField, BooleanField,
DateField, DateField,
DecimalField, DecimalField,
HiddenField,
IntegerField,
Label, Label,
PasswordField, PasswordField,
SelectField, SelectField,
@ -39,7 +41,7 @@ from wtforms.validators import (
) )
from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag
from ihatemoney.utils import ( from ihatemoney.utils import (
em_surround, em_surround,
eval_arithmetic_expression, eval_arithmetic_expression,
@ -134,7 +136,8 @@ class EditProjectForm(FlaskForm):
_("New private code"), _("New private code"),
description=_("Enter a new code if you want to change it"), description=_("Enter a new code if you want to change it"),
) )
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()]) contact_email = StringField(_("Email"), validators=[
DataRequired(), Email()])
project_history = BooleanField(_("Enable project history")) project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history")) ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter() currency_helper = CurrencyConverter()
@ -227,7 +230,8 @@ class ImportProjectForm(FlaskForm):
"File", "File",
validators=[ validators=[
FileRequired(), FileRequired(),
FileAllowed(["json", "JSON", "csv", "CSV"], "Incorrect file format"), FileAllowed(["json", "JSON", "csv", "CSV"],
"Incorrect file format"),
], ],
description=_("Compatible with Cospend"), description=_("Compatible with Cospend"),
) )
@ -348,9 +352,11 @@ class ResetPasswordForm(FlaskForm):
class BillForm(FlaskForm): class BillForm(FlaskForm):
date = DateField(_("When?"), validators=[DataRequired()], default=datetime.now) date = DateField(_("When?"), validators=[
DataRequired()], default=datetime.now)
what = StringField(_("What?"), validators=[DataRequired()]) what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int) payer = SelectField(_("Who paid?"), validators=[
DataRequired()], coerce=int)
amount = CalculatorStringField(_("How much?"), validators=[DataRequired()]) amount = CalculatorStringField(_("How much?"), validators=[DataRequired()])
currency_helper = CurrencyConverter() currency_helper = CurrencyConverter()
original_currency = SelectField(_("Currency"), validators=[DataRequired()]) original_currency = SelectField(_("Currency"), validators=[DataRequired()])
@ -372,8 +378,30 @@ class BillForm(FlaskForm):
submit = SubmitField(_("Submit")) submit = SubmitField(_("Submit"))
submit2 = SubmitField(_("Submit and add a new one")) submit2 = SubmitField(_("Submit and add a new one"))
def parse_hashtags(self, project, what):
"""Handles the hashtags which can be optionally specified in the 'what'
field, using the `grocery #hash #otherhash` syntax.
Returns: the new "what" field (with hashtags stripped-out) and the list
of tags.
"""
hashtags = findall(r"#(\w+)", what)
if not hashtags:
return what, []
for tag in hashtags:
what = what.replace(f"#{tag}", "")
return what, hashtags
def export(self, project): def export(self, project):
return Bill( """This is triggered on bill creation.
"""
what, hashtags = self.parse_hashtags(project, self.what.data)
bill = Bill(
amount=float(self.amount.data), amount=float(self.amount.data),
date=self.date.data, date=self.date.data,
external_link=self.external_link.data, external_link=self.external_link.data,
@ -381,14 +409,17 @@ class BillForm(FlaskForm):
owers=Person.query.get_by_ids(self.payed_for.data, project), owers=Person.query.get_by_ids(self.payed_for.data, project),
payer_id=self.payer.data, payer_id=self.payer.data,
project_default_currency=project.default_currency, project_default_currency=project.default_currency,
what=self.what.data, what=what,
bill_type=self.bill_type.data, bill_type=self.bill_type.data,
) )
bill.set_tags(hashtags, project)
return bill
def save(self, bill, project): def save(self, bill, project):
what, hashtags = self.parse_hashtags(project, self.what.data)
bill.payer_id = self.payer.data bill.payer_id = self.payer.data
bill.amount = self.amount.data bill.amount = self.amount.data
bill.what = self.what.data bill.what = what
bill.bill_type = BillType(self.bill_type.data) bill.bill_type = BillType(self.bill_type.data)
bill.external_link = self.external_link.data bill.external_link = self.external_link.data
bill.date = self.date.data bill.date = self.date.data
@ -397,19 +428,22 @@ class BillForm(FlaskForm):
bill.converted_amount = self.currency_helper.exchange_currency( bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency bill.amount, bill.original_currency, project.default_currency
) )
bill.set_tags(hashtags, project)
return bill return bill
def fill(self, bill, project): def fill(self, bill, project):
self.payer.data = bill.payer_id self.payer.data = bill.payer_id
self.amount.data = bill.amount self.amount.data = bill.amount
self.what.data = bill.what hashtags = ' '.join([f'#{tag.name}' for tag in bill.tags])
self.what.data = bill.what.strip() + f' {hashtags}'
self.bill_type.data = bill.bill_type self.bill_type.data = bill.bill_type
self.external_link.data = bill.external_link self.external_link.data = bill.external_link
self.original_currency.data = bill.original_currency self.original_currency.data = bill.original_currency
self.date.data = bill.date self.date.data = bill.date
self.payed_for.data = [int(ower.id) for ower in bill.owers] self.payed_for.data = [int(ower.id) for ower in bill.owers]
self.original_currency.label = Label("original_currency", _("Currency")) self.original_currency.label = Label(
"original_currency", _("Currency"))
self.original_currency.description = _( self.original_currency.description = _(
"Project default: %(currency)s", "Project default: %(currency)s",
currency=render_localized_currency( currency=render_localized_currency(
@ -437,11 +471,30 @@ class BillForm(FlaskForm):
raise ValidationError(msg) raise ValidationError(msg)
class MemberForm(FlaskForm): class HiddenCommaDecimalField(HiddenField, CommaDecimalField):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter]) pass
weight_validators = [NumberRange(min=0.1, message=_("Weights should be positive"))]
weight = CommaDecimalField(_("Weight"), default=1, validators=weight_validators) class HiddenIntegerField(HiddenField, IntegerField):
pass
class SettlementForm(FlaskForm):
"""Used internally for validation, not directly visible to users"""
amount = HiddenCommaDecimalField("Amount", validators=[DataRequired()])
sender_id = HiddenIntegerField("Sender", validators=[DataRequired()])
receiver_id = HiddenIntegerField("Receiver", validators=[DataRequired()])
class MemberForm(FlaskForm):
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)
submit = SubmitField(_("Add")) submit = SubmitField(_("Add"))
def __init__(self, project, edit=False, *args, **kwargs): def __init__(self, project, edit=False, *args, **kwargs):
@ -460,7 +513,8 @@ class MemberForm(FlaskForm):
Person.activated, Person.activated,
).all() ).all()
): # NOQA ): # NOQA
raise ValidationError(_("This project already have this participant")) raise ValidationError(
_("This project already have this participant"))
def save(self, project, person): def save(self, project, person):
# if the user is already bound to the project, just reactivate him # if the user is already bound to the project, just reactivate him

View file

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

View file

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

View file

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

View file

@ -1,33 +1,49 @@
{% extends "sidebar_table_layout.html" %} {% extends "sidebar_table_layout.html" %} {% block sidebar %}
<div id="table_overflow">{{ balance_table(show_weight=False) }}</div>
{% block sidebar %} {% endblock %} {% block content %}
<div id="table_overflow"> <table id="bill_table" class="split_bills table table-striped">
{{ balance_table(show_weight=False) }} <thead>
</div> <tr>
{% endblock %} <th>{{ _("Who pays?") }}</th>
<th>{{ _("To whom?") }}</th>
<th>{{ _("How much?") }}</th>
{% block content %} <th>{{ _("Settled?") }}</th>
<table id="bill_table" class="split_bills table table-striped"> </tr>
<thead><tr><th>{{ _("Who pays?") }}</th><th>{{ _("To whom?") }}</th><th>{{ _("How much?") }}</th><th>{{ _("Settled?") }}</th></tr></thead> </thead>
<tbody> <tbody>
{% for bill in bills %} {% for transaction in transactions %}
<tr receiver={{bill.receiver.id}}> <tr receiver="{{transaction.receiver.id}}">
<td>{{ bill.ower }}</td> <td>{{ transaction.ower }}</td>
<td>{{ bill.receiver }}</td> <td>{{ transaction.receiver }}</td>
<td>{{ bill.amount|currency }}</td> <td>{{ transaction.amount|currency }}</td>
<td> <td>
<span id="settle-bill" class="ml-auto pb-2"> <span id="settle-bill" class="ml-auto pb-2">
<a href="{{ url_for('.settle', amount = bill.amount, ower_id = bill.ower.id, payer_id = bill.receiver.id) }}" class="btn btn-primary"> <form class="" action="{{ url_for(".add_settlement_bill") }}" method="POST">
<div data-toggle="tooltip" title='{{ _("Click here to record that the money transfer has been done") }}'> {{ settlement_form.csrf_token }}
{{ ("Settle") }} {{ settlement_form.amount(value=transaction.amount) }}
</div> {{ settlement_form.sender_id(value=transaction.ower.id) }}
</a> {{ settlement_form.receiver_id(value=transaction.receiver.id) }}
</span> <button class="btn btn-primary" type="submit" title="{{ _("Settle") }}">
</td> <div
data-toggle="tooltip"
title='{{ _("Click here to record that the money transfer has been done") }}'
>
{{ _("Settle") }}
</div>
</button>
</form>
<a
href="{{ url_for('.add_settlement_bill', amount = transaction.amount, sender_id = transaction.ower.id, receiver_id = transaction.receiver.id) }}"
class="btn btn-primary"
>
{{ ("Settle") }}
</div>
</a>
</span>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}

View file

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

View file

@ -1358,23 +1358,25 @@ class TestBudget(IhatemoneyTestCase):
count = 0 count = 0
for t in transactions: for t in transactions:
count += 1 count += 1
self.client.get( self.client.post(
"/raclette/settle" "/raclette/settle",
+ "/" data={
+ str(t["amount"]) "amount": t["amount"],
+ "/" "sender_id": t["ower"].id,
+ str(t["ower"].id) "receiver_id": t["receiver"].id,
+ "/" },
+ str(t["receiver"].id)
) )
temp_transactions = project.get_transactions_to_settle_bill() temp_transactions = project.get_transactions_to_settle_bill()
# test if the one has disappeared # test if the one has disappeared
assert len(temp_transactions) == len(transactions) - count assert len(temp_transactions) == len(transactions) - count
# test if theres a new one with bill_type reimbursement # test if there is a new one with bill_type reimbursement
bill = project.get_newest_bill() bill = project.get_newest_bill()
assert bill.bill_type == models.BillType.REIMBURSEMENT assert bill.bill_type == models.BillType.REIMBURSEMENT
return
# There should be no more settlement to do at the end
transactions = project.get_transactions_to_settle_bill()
assert len(transactions) == 0
def test_settle_zero(self): def test_settle_zero(self):
self.post_project("raclette") self.post_project("raclette")
@ -1463,6 +1465,78 @@ class TestBudget(IhatemoneyTestCase):
# Create and log in as another project # Create and log in as another project
self.post_project("tartiflette") self.post_project("tartiflette")
# Add a participant in this second project
self.client.post("/tartiflette/members/add", data={"name": "pirate"})
pirate = models.Person.query.filter(models.Person.id == 5).one()
assert pirate.name == "pirate"
# Try to add a new bill to another project
resp = self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "fromage frelaté",
"payer": 2,
"payed_for": [2, 3, 4],
"bill_type": "Expense",
"amount": "100.0",
},
)
# Ensure it has not been created
raclette = self.get_project("raclette")
assert raclette.get_bills().count() == 1
# Try to add a new bill in our project that references members of another project.
# First with invalid payed_for IDs.
resp = self.client.post(
"/tartiflette/add",
data={
"date": "2017-01-01",
"what": "soupe",
"payer": 5,
"payed_for": [3],
"bill_type": "Expense",
"amount": "5000.0",
},
)
# Ensure it has not been created
piratebill = models.Bill.query.filter(models.Bill.what == "soupe").one_or_none()
assert piratebill is None, "piratebill 1 should not exist"
# Then with invalid payer ID
self.client.post(
"/tartiflette/add",
data={
"date": "2017-02-01",
"what": "pain",
"payer": 3,
"payed_for": [5],
"bill_type": "Expense",
"amount": "5000.0",
},
)
# Ensure it has not been created
piratebill = models.Bill.query.filter(models.Bill.what == "pain").one_or_none()
assert piratebill is None, "piratebill 2 should not exist"
# Make sure we can actually create valid bills
self.client.post(
"/tartiflette/add",
data={
"date": "2017-03-01",
"what": "baguette",
"payer": 5,
"payed_for": [5],
"bill_type": "Expense",
"amount": "5.0",
},
)
# Ensure it has been created
okbill = models.Bill.query.filter(models.Bill.what == "baguette").one_or_none()
assert okbill is not None, "Bill baguette should exist"
assert okbill.what == "baguette"
# Now try to access and modify existing bills
modified_bill = { modified_bill = {
"date": "2018-12-31", "date": "2018-12-31",
"what": "roblochon", "what": "roblochon",
@ -1556,6 +1630,24 @@ class TestBudget(IhatemoneyTestCase):
member = models.Person.query.filter(models.Person.id == 1).one_or_none() member = models.Person.query.filter(models.Person.id == 1).one_or_none()
assert member is None assert member is None
# test new settle endpoint to add bills with wrong ids
self.client.post("/exit")
self.client.post(
"/authenticate", data={"id": "tartiflette", "password": "tartiflette"}
)
self.client.post(
"/tartiflette/settle",
data={
"sender_id": 4,
"receiver_id": 5,
"amount": "42.0",
},
)
piratebill = models.Bill.query.filter(
models.Bill.bill_type == models.BillType.REIMBURSEMENT
).one_or_none()
assert piratebill is None, "piratebill 3 should not exist"
@pytest.mark.skip(reason="Currency conversion is broken") @pytest.mark.skip(reason="Currency conversion is broken")
def test_currency_switch(self): def test_currency_switch(self):
# A project should be editable # A project should be editable

View file

@ -452,7 +452,9 @@ def format_form_errors(form, prefix):
) )
else: else:
error_list = "</li><li>".join( error_list = "</li><li>".join(
str(error) for (field, errors) in form.errors.items() for error in errors f"<strong>{field}</strong> {error}"
for (field, errors) in form.errors.items()
for error in errors
) )
errors = f"<ul><li>{error_list}</li></ul>" errors = f"<ul><li>{error_list}</li></ul>"
# I18N: Form error with a list of errors # I18N: Form error with a list of errors

View file

@ -2,7 +2,7 @@
The blueprint for the web interface. The blueprint for the web interface.
Contains all the interaction logic with the end user (except forms which Contains all the interaction logic with the end user (except forms which
are directly handled in the forms module. are directly handled in the forms module).
Basically, this blueprint takes care of the authentication and provides Basically, this blueprint takes care of the authentication and provides
some shortcuts to make your life better when coding (see `pull_project` some shortcuts to make your life better when coding (see `pull_project`
@ -10,12 +10,14 @@ and `add_project_id` for a quick overview)
""" """
import datetime import datetime
from functools import wraps
import hashlib import hashlib
import json import json
import os import os
from functools import wraps
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
import qrcode
import qrcode.image.svg
from flask import ( from flask import (
Blueprint, Blueprint,
Response, Response,
@ -34,8 +36,6 @@ from flask import (
) )
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_mail import Message from flask_mail import Message
import qrcode
import qrcode.image.svg
from sqlalchemy_continuum import Operation from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
@ -56,10 +56,11 @@ from ihatemoney.forms import (
ProjectForm, ProjectForm,
ProjectFormWithCaptcha, ProjectFormWithCaptcha,
ResetPasswordForm, ResetPasswordForm,
SettlementForm,
get_billform_for, get_billform_for,
) )
from ihatemoney.history import get_history, get_history_queries, purge_history from ihatemoney.history import get_history, get_history_queries, purge_history
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, db from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag, db
from ihatemoney.utils import ( from ihatemoney.utils import (
Redirect303, Redirect303,
csv2list_of_dicts, csv2list_of_dicts,
@ -160,7 +161,8 @@ def pull_project(endpoint, values):
project_id = entered_project_id.lower() project_id = entered_project_id.lower()
project = Project.query.get(project_id) project = Project.query.get(project_id)
if not project: if not project:
raise Redirect303(url_for(".create_project", project_id=project_id)) raise Redirect303(
url_for(".create_project", project_id=project_id))
is_admin = session.get("is_admin") is_admin = session.get("is_admin")
is_invitation = endpoint == "main.join_project" is_invitation = endpoint == "main.join_project"
@ -374,7 +376,8 @@ def remind_password():
# send a link to reset the password # send a link to reset the password
remind_message = Message( remind_message = Message(
"password recovery", "password recovery",
body=render_localized_template("password_reminder", project=project), body=render_localized_template(
"password_reminder", project=project),
recipients=[project.contact_email], recipients=[project.contact_email],
) )
success = send_email(remind_message) success = send_email(remind_message)
@ -617,7 +620,8 @@ def invite():
msg = Message( msg = Message(
message_title, message_title,
body=message_body, body=message_body,
recipients=[email.strip() for email in form.emails.data.split(",")], recipients=[email.strip()
for email in form.emails.data.split(",")],
) )
success = send_email(msg) success = send_email(msg)
if success: if success:
@ -638,7 +642,8 @@ def invite():
token=g.project.generate_token(), token=g.project.generate_token(),
_external=True, _external=True,
) )
invite_link = urlunparse(urlparse(invite_link)._replace(scheme="ihatemoney")) invite_link = urlunparse(
urlparse(invite_link)._replace(scheme="ihatemoney"))
qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage) qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage)
qr.add_data(invite_link) qr.add_data(invite_link)
qr.make(fit=True) qr.make(fit=True)
@ -852,24 +857,46 @@ def change_lang(lang):
@main.route("/<project_id>/settle_bills") @main.route("/<project_id>/settle_bills")
def settle_bill(): def settle_bill():
"""Compute the sum each one have to pay to each other and display it""" """Compute the sum each one have to pay to each other and display it"""
bills = g.project.get_transactions_to_settle_bill() transactions = g.project.get_transactions_to_settle_bill()
return render_template("settle_bills.html", bills=bills, current_view="settle_bill") settlement_form = SettlementForm()
return render_template(
"settle_bills.html",
transactions=transactions,
settlement_form=settlement_form,
current_view="settle_bill",
)
@main.route("/<project_id>/settle/<amount>/<int:ower_id>/<int:payer_id>") @main.route("/<project_id>/settle", methods=["POST"])
def settle(amount, ower_id, payer_id): def add_settlement_bill():
new_reinbursement = Bill( """Create a bill to register a settlement"""
amount=float(amount), form = SettlementForm(id=g.project.id)
if not form.validate():
flash(
format_form_errors(form, _("Error creating settlement bill")),
category="danger",
)
return redirect(url_for(".settle_bill"))
# Ensure that the sender and receiver ID are valid and part of this project
receiver_id = form.receiver_id.data
sender_id = form.sender_id.data
if not g.project.has_member(sender_id):
return redirect(url_for(".settle_bill"))
settlement = Bill(
amount=form.amount.data,
date=datetime.datetime.today(), date=datetime.datetime.today(),
owers=[Person.query.get(payer_id)], owers=[Person.query.get(receiver_id, g.project)],
payer_id=ower_id, payer_id=sender_id,
project_default_currency=g.project.default_currency, project_default_currency=g.project.default_currency,
bill_type=BillType.REIMBURSEMENT, bill_type=BillType.REIMBURSEMENT,
what=_("Settlement"), what=_("Settlement"),
) )
session.update() session.update()
db.session.add(new_reinbursement) db.session.add(settlement)
db.session.commit() db.session.commit()
flash(_("Settlement bill has been successfully added"), category="success") flash(_("Settlement bill has been successfully added"), category="success")
@ -920,7 +947,8 @@ def strip_ip_addresses():
form = DestructiveActionProjectForm(id=g.project.id) form = DestructiveActionProjectForm(id=g.project.id)
if not form.validate(): if not form.validate():
flash( flash(
format_form_errors(form, _("Error deleting recorded IP addresses")), format_form_errors(
form, _("Error deleting recorded IP addresses")),
category="danger", category="danger",
) )
return redirect(url_for(".history")) return redirect(url_for(".history"))
@ -939,18 +967,22 @@ def statistics():
"""Compute what each participant has paid and spent and display it""" """Compute what each participant has paid and spent and display it"""
# Determine range of months between which there are bills # Determine range of months between which there are bills
months = g.project.active_months_range() months = g.project.active_months_range()
tags = Tag.query.filter(Tag.project_id == g.project.id)
return render_template( return render_template(
"statistics.html", "statistics.html",
members_stats=g.project.members_stats, members_stats=g.project.members_stats,
monthly_stats=g.project.monthly_stats, monthly_stats=g.project.monthly_stats,
tags_monthly_stats=g.project.tags_monthly_stats,
months=months, months=months,
tags=tags,
current_view="statistics", current_view="statistics",
) )
def build_etag(project_id, last_modified): def build_etag(project_id, last_modified):
return hashlib.md5( return hashlib.md5(
(current_app.config["SECRET_KEY"] + project_id + last_modified).encode() (current_app.config["SECRET_KEY"] +
project_id + last_modified).encode()
).hexdigest() ).hexdigest()