mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
Merge f968c9870c
into 19ecdb5052
This commit is contained in:
commit
488e4e2fc9
6 changed files with 281 additions and 45 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
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)
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue