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.
This commit is contained in:
Alexis Métaireau 2024-05-16 01:09:35 +02:00
parent eb6e156c32
commit 14cc9b96d3
No known key found for this signature in database
GPG key ID: 1C21B876828E5FF2
4 changed files with 132 additions and 10 deletions

View file

@ -1,6 +1,6 @@
from datetime import datetime
import decimal
from re import match
from datetime import datetime
from re import findall, match
from types import SimpleNamespace
import email_validator
@ -39,7 +39,7 @@ from wtforms.validators import (
)
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag
from ihatemoney.utils import (
em_surround,
eval_arithmetic_expression,
@ -389,6 +389,12 @@ class BillForm(FlaskForm):
def save(self, bill, project):
bill.payer_id = self.payer.data
bill.amount = self.amount.data
# Get the list of tags from the 'what' field
hashtags = findall(r"#(\w+)", self.what.data)
if hashtags:
bill.tags = [Tag(name=tag) for tag in hashtags]
for tag in hashtags:
self.what.data = self.what.data.replace(f"#{tag}", "")
bill.what = self.what.data
bill.bill_type = BillType(self.bill_type.data)
bill.external_link = self.external_link.data

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
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
@ -649,6 +649,29 @@ billowers = db.Table(
)
class Tag(db.Model):
__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
# 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,
)
class Bill(db.Model):
class BillQuery(BaseQuery):
def get(self, project, id):
@ -688,6 +711,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)
@ -790,3 +814,4 @@ sqlalchemy.orm.configure_mappers()
PersonVersion = version_class(Person)
ProjectVersion = version_class(Project)
BillVersion = version_class(Bill)
# TagVersion = version_class(Tag)

View file

@ -2,19 +2,21 @@
The blueprint for the web interface.
Contains all the interaction logic with the end user (except forms which
are directly handled in the forms module.
are directly handled in the forms module).
Basically, this blueprint takes care of the authentication and provides
some shortcuts to make your life better when coding (see `pull_project`
and `add_project_id` for a quick overview)
"""
import datetime
from functools import wraps
import hashlib
import json
import os
from functools import wraps
from urllib.parse import urlparse, urlunparse
import qrcode
import qrcode.image.svg
from flask import (
Blueprint,
Response,
@ -33,8 +35,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