diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index e1ddabe87..955f5af52 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 @@ -39,7 +39,7 @@ ) 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 @@ def export(self, project): 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 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 000000000..d3c9d6162 --- /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 c591b85b6..224cfed68 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 @@ 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 @@ def __repr__(self): ) +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 @@ def delete(self, project, id): 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 @@ def __repr__(self): PersonVersion = version_class(Person) ProjectVersion = version_class(Project) BillVersion = version_class(Bill) +# TagVersion = version_class(Tag) diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 0d0bdd201..0cec48646 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -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_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