This commit is contained in:
Alexis Métaireau 2025-01-05 22:11:46 +01:00 committed by GitHub
commit 488e4e2fc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 281 additions and 45 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
@ -41,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,
@ -136,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()
@ -229,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"),
) )
@ -350,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()])
@ -374,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,
@ -383,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
@ -399,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(
@ -456,10 +488,13 @@ class SettlementForm(FlaskForm):
class MemberForm(FlaskForm): class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter]) name = StringField(_("Name"), validators=[
DataRequired()], filters=[strip_filter])
weight_validators = [NumberRange(min=0.1, message=_("Weights should be positive"))] weight_validators = [NumberRange(
weight = CommaDecimalField(_("Weight"), default=1, validators=weight_validators) min=0.1, message=_("Weights should be positive"))]
weight = CommaDecimalField(
_("Weight"), default=1, validators=weight_validators)
submit = SubmitField(_("Add")) submit = SubmitField(_("Add"))
def __init__(self, project, edit=False, *args, **kwargs): def __init__(self, project, edit=False, *args, **kwargs):
@ -478,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
@ -501,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
@ -647,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,
) )
@ -692,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)
@ -756,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 "
@ -794,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

@ -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

@ -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
@ -60,7 +60,7 @@ from ihatemoney.forms import (
get_billform_for, get_billform_for,
) )
from ihatemoney.history import get_history, get_history_queries, purge_history from ihatemoney.history import get_history, get_history_queries, purge_history
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, db from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag, db
from ihatemoney.utils import ( from ihatemoney.utils import (
Redirect303, Redirect303,
csv2list_of_dicts, csv2list_of_dicts,
@ -161,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"
@ -375,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)
@ -618,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:
@ -639,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)
@ -943,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"))
@ -962,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()