diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 485867a2..f2bc486a 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -1,6 +1,6 @@ -from datetime import datetime import decimal -from re import match +from datetime import datetime +from re import findall, match from types import SimpleNamespace import email_validator @@ -41,7 +41,7 @@ from wtforms.validators import ( ) from ihatemoney.currency_convertor import CurrencyConverter -from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project +from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag from ihatemoney.utils import ( em_surround, eval_arithmetic_expression, @@ -136,7 +136,8 @@ class EditProjectForm(FlaskForm): _("New private code"), description=_("Enter a new code if you want to change it"), ) - contact_email = StringField(_("Email"), validators=[DataRequired(), Email()]) + contact_email = StringField(_("Email"), validators=[ + DataRequired(), Email()]) project_history = BooleanField(_("Enable project history")) ip_recording = BooleanField(_("Use IP tracking for project history")) currency_helper = CurrencyConverter() @@ -229,7 +230,8 @@ class ImportProjectForm(FlaskForm): "File", validators=[ FileRequired(), - FileAllowed(["json", "JSON", "csv", "CSV"], "Incorrect file format"), + FileAllowed(["json", "JSON", "csv", "CSV"], + "Incorrect file format"), ], description=_("Compatible with Cospend"), ) @@ -350,9 +352,11 @@ class ResetPasswordForm(FlaskForm): class BillForm(FlaskForm): - date = DateField(_("When?"), validators=[DataRequired()], default=datetime.now) + date = DateField(_("When?"), validators=[ + DataRequired()], default=datetime.now) what = StringField(_("What?"), validators=[DataRequired()]) - payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int) + payer = SelectField(_("Who paid?"), validators=[ + DataRequired()], coerce=int) amount = CalculatorStringField(_("How much?"), validators=[DataRequired()]) currency_helper = CurrencyConverter() original_currency = SelectField(_("Currency"), validators=[DataRequired()]) @@ -374,8 +378,30 @@ class BillForm(FlaskForm): submit = SubmitField(_("Submit")) submit2 = SubmitField(_("Submit and add a new one")) + def parse_hashtags(self, project, what): + """Handles the hashtags which can be optionally specified in the 'what' + field, using the `grocery #hash #otherhash` syntax. + + Returns: the new "what" field (with hashtags stripped-out) and the list + of tags. + """ + + hashtags = findall(r"#(\w+)", what) + + if not hashtags: + return what, [] + + for tag in hashtags: + what = what.replace(f"#{tag}", "") + + return what, hashtags + def export(self, project): - return Bill( + """This is triggered on bill creation. + """ + what, hashtags = self.parse_hashtags(project, self.what.data) + + bill = Bill( amount=float(self.amount.data), date=self.date.data, external_link=self.external_link.data, @@ -383,14 +409,17 @@ class BillForm(FlaskForm): owers=Person.query.get_by_ids(self.payed_for.data, project), payer_id=self.payer.data, project_default_currency=project.default_currency, - what=self.what.data, + what=what, bill_type=self.bill_type.data, ) + bill.set_tags(hashtags, project) + return bill def save(self, bill, project): + what, hashtags = self.parse_hashtags(project, self.what.data) bill.payer_id = self.payer.data bill.amount = self.amount.data - bill.what = self.what.data + bill.what = what bill.bill_type = BillType(self.bill_type.data) bill.external_link = self.external_link.data bill.date = self.date.data @@ -399,19 +428,22 @@ class BillForm(FlaskForm): bill.converted_amount = self.currency_helper.exchange_currency( bill.amount, bill.original_currency, project.default_currency ) + bill.set_tags(hashtags, project) return bill def fill(self, bill, project): self.payer.data = bill.payer_id self.amount.data = bill.amount - self.what.data = bill.what + hashtags = ' '.join([f'#{tag.name}' for tag in bill.tags]) + self.what.data = bill.what.strip() + f' {hashtags}' self.bill_type.data = bill.bill_type self.external_link.data = bill.external_link self.original_currency.data = bill.original_currency self.date.data = bill.date self.payed_for.data = [int(ower.id) for ower in bill.owers] - self.original_currency.label = Label("original_currency", _("Currency")) + self.original_currency.label = Label( + "original_currency", _("Currency")) self.original_currency.description = _( "Project default: %(currency)s", currency=render_localized_currency( @@ -456,10 +488,13 @@ class SettlementForm(FlaskForm): class MemberForm(FlaskForm): - name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter]) + name = StringField(_("Name"), validators=[ + DataRequired()], filters=[strip_filter]) - weight_validators = [NumberRange(min=0.1, message=_("Weights should be positive"))] - weight = CommaDecimalField(_("Weight"), default=1, validators=weight_validators) + weight_validators = [NumberRange( + min=0.1, message=_("Weights should be positive"))] + weight = CommaDecimalField( + _("Weight"), default=1, validators=weight_validators) submit = SubmitField(_("Add")) def __init__(self, project, edit=False, *args, **kwargs): @@ -478,7 +513,8 @@ class MemberForm(FlaskForm): Person.activated, ).all() ): # NOQA - raise ValidationError(_("This project already have this participant")) + raise ValidationError( + _("This project already have this participant")) def save(self, project, person): # if the user is already bound to the project, just reactivate him diff --git a/ihatemoney/migrations/versions/d53fe61e5521_add_a_tags_table.py b/ihatemoney/migrations/versions/d53fe61e5521_add_a_tags_table.py new file mode 100644 index 00000000..d3c9d616 --- /dev/null +++ b/ihatemoney/migrations/versions/d53fe61e5521_add_a_tags_table.py @@ -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 ### diff --git a/ihatemoney/models.py b/ihatemoney/models.py index af21994d..c8c82890 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -1,8 +1,9 @@ -from collections import defaultdict import datetime -from enum import Enum import itertools +from collections import defaultdict +from enum import Enum +import sqlalchemy from dateutil.parser import parse from dateutil.relativedelta import relativedelta from debts import settle @@ -14,7 +15,6 @@ from itsdangerous import ( URLSafeSerializer, URLSafeTimedSerializer, ) -import sqlalchemy from sqlalchemy import orm from sqlalchemy.sql import func from sqlalchemy_continuum import make_versioned, version_class @@ -125,7 +125,8 @@ class Project(db.Model): balance spent paid """ - balances, should_pay, should_receive = (defaultdict(int) for time in (1, 2, 3)) + balances, should_pay, should_receive = ( + defaultdict(int) for time in (1, 2, 3)) for bill in self.get_bills_unordered().all(): total_weight = sum(ower.weight for ower in bill.owers) @@ -181,11 +182,28 @@ class Project(db.Model): :rtype dict: """ monthly = defaultdict(lambda: defaultdict(float)) + for bill in self.get_bills_unordered().all(): if bill.bill_type == BillType.EXPENSE: monthly[bill.date.year][bill.date.month] += bill.converted_amount return monthly + @property + def tags_monthly_stats(self): + """ + + :return: a dict of years mapping to a dict of months mapping to the amount + :rtype dict: + """ + tags_monthly = defaultdict( + lambda: defaultdict(lambda: defaultdict(float))) + + for bill in self.get_bills_unordered().all(): + if bill.bill_type == BillType.EXPENSE: + for tag in bill.tags: + tags_monthly[bill.date.year][bill.date.month][tag.name] += bill.converted_amount + return tags_monthly + @property def uses_weights(self): return len([i for i in self.members if i.weight != 1]) > 0 @@ -322,7 +340,8 @@ class Project(db.Model): year=newest_date.year, month=newest_date.month, day=1 ) # Infinite iterator towards the past - all_months = (newest_month - relativedelta(months=i) for i in itertools.count()) + all_months = (newest_month - relativedelta(months=i) + for i in itertools.count()) # Stop when reaching one month before the first date months = itertools.takewhile( lambda x: x > oldest_date - relativedelta(months=1), all_months @@ -501,7 +520,8 @@ class Project(db.Model): ) loads_kwargs["max_age"] = max_age else: - project = Project.query.get(project_id) if project_id is not None else None + project = Project.query.get( + project_id) if project_id is not None else None password = project.password if project is not None else "" serializer = URLSafeSerializer( current_app.config["SECRET_KEY"] + password, salt=token_type @@ -647,8 +667,50 @@ class Person(db.Model): # We need to manually define a join table for m2m relations billowers = db.Table( "billowers", - db.Column("bill_id", db.Integer, db.ForeignKey("bill.id"), primary_key=True), - db.Column("person_id", db.Integer, db.ForeignKey("person.id"), primary_key=True), + db.Column("bill_id", db.Integer, db.ForeignKey( + "bill.id"), primary_key=True), + db.Column("person_id", db.Integer, db.ForeignKey( + "person.id"), primary_key=True), + sqlite_autoincrement=True, +) + + +class Tag(db.Model): + class TagQuery(BaseQuery): + def get_or_create(self, name, project): + exists = ( + Tag.query.filter(Tag.name == name) + .filter(Tag.project_id == project.id) + .one_or_none() + ) + if exists: + return exists + return Tag(name=name, project_id=project.id) + + query_class = TagQuery + + __versionned__ = {} + + __table_args__ = {"sqlite_autoincrement": True} + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.String(64), db.ForeignKey("project.id")) + # bills = db.relationship("Bill", backref="tags") + + name = db.Column(db.UnicodeText) + + def __str__(self): + return self.name + + def __repr__(self): + return self.name + + +# We need to manually define a join table for m2m relations +billtags = db.Table( + "billtags", + db.Column("bill_id", db.Integer, db.ForeignKey( + "bill.id"), primary_key=True), + db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True), sqlite_autoincrement=True, ) @@ -692,6 +754,7 @@ class Bill(db.Model): what = db.Column(db.UnicodeText) bill_type = db.Column(db.Enum(BillType)) external_link = db.Column(db.UnicodeText) + tags = db.relationship(Tag, secondary=billtags) original_currency = db.Column(db.String(3)) converted_amount = db.Column(db.Float) @@ -756,15 +819,24 @@ class Bill(db.Model): else: return 0 - def __str__(self): - return self.what - def pay_each(self): """Warning: this is slow, if you need to compute this for many bills, do it differently (see balance_full function) """ return self.pay_each_default(self.converted_amount) + def set_tags(self, tags, project): + object_tags = [] + for tag_name in tags: + tag = Tag.query.get_or_create(name=tag_name, project=project) + db.session.add(tag) + object_tags.append(tag) + self.tags = object_tags + db.session.commit() + + def __str__(self): + return self.what + def __repr__(self): return ( f" 0 %} - + + + + + + + + {% for (weights, bill) in bills.items %} @@ -147,6 +153,11 @@ {{ weighted_bill_amount(bill, weights) }} +
{{ _("When?") }} - {{ _("Who paid?") }} - {{ _("For what?") }} - {{ _("For whom?") }} - {{ _("How much?") }} - {{ _("Actions") }}
{{ _("When?") }}{{ _("Who paid?") }}{{ _("For what?") }}{{ _("For whom?") }}{{ _("How much?") }} + {{ _("Tags") }} + {{ _("Actions") }}
+ {% for tag in bill.tags %} + #{{ tag.name }} + {% endfor %} + {{ _('edit') }}
diff --git a/ihatemoney/templates/statistics.html b/ihatemoney/templates/statistics.html index 86f9cd42..c1f80e88 100644 --- a/ihatemoney/templates/statistics.html +++ b/ihatemoney/templates/statistics.html @@ -23,12 +23,28 @@

{{ _("Expenses by Month") }}

- + + + + + {% for tag in tags %} + + {% endfor %} + + {% for month in months %} + {% for tag in tags %} + {% if tag.name in tags_monthly_stats[month.year][month.month] %} + + {% else %} + + {% endif %} + {% endfor %} + {% endfor %} diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 37bd811f..b10e3371 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -2,7 +2,7 @@ The blueprint for the web interface. Contains all the interaction logic with the end user (except forms which -are directly handled in the forms module. +are directly handled in the forms module). Basically, this blueprint takes care of the authentication and provides some shortcuts to make your life better when coding (see `pull_project` @@ -10,12 +10,14 @@ and `add_project_id` for a quick overview) """ import datetime -from functools import wraps import hashlib import json import os +from functools import wraps from urllib.parse import urlparse, urlunparse +import qrcode +import qrcode.image.svg from flask import ( Blueprint, Response, @@ -34,8 +36,6 @@ from flask import ( ) from flask_babel import gettext as _ from flask_mail import Message -import qrcode -import qrcode.image.svg from sqlalchemy_continuum import Operation from werkzeug.exceptions import NotFound from werkzeug.security import check_password_hash @@ -60,7 +60,7 @@ from ihatemoney.forms import ( get_billform_for, ) from ihatemoney.history import get_history, get_history_queries, purge_history -from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, db +from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag, db from ihatemoney.utils import ( Redirect303, csv2list_of_dicts, @@ -161,7 +161,8 @@ def pull_project(endpoint, values): project_id = entered_project_id.lower() project = Project.query.get(project_id) if not project: - raise Redirect303(url_for(".create_project", project_id=project_id)) + raise Redirect303( + url_for(".create_project", project_id=project_id)) is_admin = session.get("is_admin") is_invitation = endpoint == "main.join_project" @@ -375,7 +376,8 @@ def remind_password(): # send a link to reset the password remind_message = Message( "password recovery", - body=render_localized_template("password_reminder", project=project), + body=render_localized_template( + "password_reminder", project=project), recipients=[project.contact_email], ) success = send_email(remind_message) @@ -618,7 +620,8 @@ def invite(): msg = Message( message_title, body=message_body, - recipients=[email.strip() for email in form.emails.data.split(",")], + recipients=[email.strip() + for email in form.emails.data.split(",")], ) success = send_email(msg) if success: @@ -639,7 +642,8 @@ def invite(): token=g.project.generate_token(), _external=True, ) - invite_link = urlunparse(urlparse(invite_link)._replace(scheme="ihatemoney")) + invite_link = urlunparse( + urlparse(invite_link)._replace(scheme="ihatemoney")) qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage) qr.add_data(invite_link) qr.make(fit=True) @@ -943,7 +947,8 @@ def strip_ip_addresses(): form = DestructiveActionProjectForm(id=g.project.id) if not form.validate(): flash( - format_form_errors(form, _("Error deleting recorded IP addresses")), + format_form_errors( + form, _("Error deleting recorded IP addresses")), category="danger", ) return redirect(url_for(".history")) @@ -962,18 +967,22 @@ def statistics(): """Compute what each participant has paid and spent and display it""" # Determine range of months between which there are bills months = g.project.active_months_range() + tags = Tag.query.filter(Tag.project_id == g.project.id) return render_template( "statistics.html", members_stats=g.project.members_stats, monthly_stats=g.project.monthly_stats, + tags_monthly_stats=g.project.tags_monthly_stats, months=months, + tags=tags, current_view="statistics", ) def build_etag(project_id, last_modified): return hashlib.md5( - (current_app.config["SECRET_KEY"] + project_id + last_modified).encode() + (current_app.config["SECRET_KEY"] + + project_id + last_modified).encode() ).hexdigest()
{{ _("Period") }}{{ _("Spent") }}
{{ _("Period") }}{{ _("Spent") }}#{{ tag.name }}
{{ month|dateformat("MMMM yyyy") }} {{ monthly_stats[month.year][month.month]|currency }}{{ tags_monthly_stats[month.year][month.month][tag.name]|currency }} -