diff --git a/accountant/api/models/entries.py b/accountant/api/models/entries.py deleted file mode 100644 index bb71687..0000000 --- a/accountant/api/models/entries.py +++ /dev/null @@ -1,69 +0,0 @@ -""" - This file is part of Accountant. - - Accountant is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Accountant is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with Accountant. If not, see . -""" - -from sqlalchemy import distinct, func, case, cast, extract - -from accountant import db - - -class Entry(db.Model): - id = db.Column(db.Integer, primary_key=True) - pointed = db.Column(db.Boolean, nullable=False, default=False) - operation_date = db.Column(db.Date, nullable=False) - label = db.Column(db.String(500), nullable=False) - value = db.Column(db.Numeric(15, 2), nullable=False) - account_id = db.Column(db.Integer, db.ForeignKey('account.id')) - scheduled_operation_id = db.Column(db.Integer, - db.ForeignKey('scheduled_operation.id')) - - account = db.relationship("Account", backref=db.backref('entry', - lazy="dynamic")) - scheduled_operation = db.relationship("ScheduledOperation", - backref=db.backref('entry', - lazy="dynamic")) - - category = db.Column(db.String(100), nullable=True) - - def __init__(self, pointed, label, value, account_id, operation_date=None, - category=None, scheduled_operation_id=None): - self.pointed = pointed - self.operation_date = operation_date - self.label = label - self.value = value - self.account_id = account_id - self.category = category - self.scheduled_operation_id = scheduled_operation_id - - @classmethod - def get_months_for_account(cls, session, account): - if isinstance(account, int) or isinstance(account, str): - account_id = account - else: - account_id = account.id - - query = session.query( - distinct(func.lpad(cast(extract("year", cls.operation_date), - db.String), 4, '0')).label("year"), - func.lpad(cast(extract("month", cls.operation_date), - db.String), 2, '0').label("month") - ).filter(cls.account_id == account_id).order_by("year", "month") - - return query - - @classmethod - def get(cls, session, id): - return session.query(cls).filter(cls.id == id).one() diff --git a/accountant/api/models/operations.py b/accountant/api/models/operations.py index e6e2968..771002e 100644 --- a/accountant/api/models/operations.py +++ b/accountant/api/models/operations.py @@ -16,36 +16,68 @@ """ import arrow -from sqlalchemy import func, case, desc, false +from sqlalchemy import func, case, desc, true, false from accountant import db class Operation(db.Model): id = db.Column(db.Integer, primary_key=True) - pointed = db.Column(db.Boolean, nullable=False, default=False) + operation_date = db.Column(db.Date, nullable=False) label = db.Column(db.String(500), nullable=False) value = db.Column(db.Numeric(15, 2), nullable=False) - account_id = db.Column(db.Integer, db.ForeignKey('account.id')) - scheduled_operation_id = db.Column(db.Integer, - db.ForeignKey('scheduled_operation.id')) - account = db.relationship('Account', backref=db.backref('operation', - lazy="dynamic")) + scheduled_operation_id = db.Column( + db.Integer, + db.ForeignKey('scheduled_operation.id') + ) + + scheduled_operation = db.relationship( + "ScheduledOperation", backref=db.backref('entry', lazy="dynamic") + ) + + account_id = db.Column(db.Integer, db.ForeignKey('account.id')) + + account = db.relationship( + 'Account', backref=db.backref('operation', lazy="dynamic") + ) category = db.Column(db.String(100), nullable=True) - canceled = db.Column(db.Boolean, nullable=False) + pointed = db.Column( + db.Boolean, + nullable=False, + default=False, + server_default=false() + ) - def __init__(self, pointed, label, value, account_id, operation_date=None, - category=None): - self.pointed = pointed + confirmed = db.Column( + db.Boolean, + nullable=False, + default=True, + server_default=true() + ) + + canceled = db.Column( + db.Boolean, + nullable=False, + default=False, + server_default=false() + ) + + def __init__(self, label, value, account_id, operation_date=None, + category=None, pointed=False, confirmed=True, canceled=False, + scheduled_operation_id=None): self.operation_date = operation_date self.label = label self.value = value - self.account_id = account_id self.category = category + self.account_id = account_id + self.scheduled_operation_id = scheduled_operation_id + self.pointed = pointed + self.confirmed = confirmed + self.canceled = canceled @classmethod def get_for_account_and_range(cls, session, account, begin, end): diff --git a/accountant/api/models/scheduled_operations.py b/accountant/api/models/scheduled_operations.py index 2dfa700..4819864 100644 --- a/accountant/api/models/scheduled_operations.py +++ b/accountant/api/models/scheduled_operations.py @@ -14,28 +14,42 @@ You should have received a copy of the GNU Affero General Public License along with Accountant. If not, see . """ -from sqlalchemy import desc +from calendar import monthrange + +from sqlalchemy import desc, false + +import arrow from accountant import db from .accounts import Account +from .operations import Operation class ScheduledOperation(db.Model): id = db.Column(db.Integer, primary_key=True) + start_date = db.Column(db.Date, nullable=False) stop_date = db.Column(db.Date, nullable=False) + day = db.Column(db.Integer, nullable=False) frequency = db.Column(db.Integer, nullable=False) + label = db.Column(db.String(500), nullable=False) value = db.Column(db.Numeric(15, 2), nullable=False) - account_id = db.Column(db.Integer, db.ForeignKey('account.id')) - - account = db.relationship(Account, backref=db.backref('scheduled_operation', - lazy="dynamic")) category = db.Column(db.String(100), nullable=True) + account_id = db.Column( + db.Integer, + db.ForeignKey('account.id') + ) + + account = db.relationship( + Account, + backref=db.backref('scheduled_operation', lazy="dynamic") + ) + def __init__(self, start_date, stop_date, day, frequency, label, value, account_id, category=None): self.start_date = start_date @@ -56,3 +70,50 @@ class ScheduledOperation(db.Model): cls.value, cls.label, ) + + def reschedule(self, session): + # 1) delete unconfirmed operations for this account. + session.query( + Operation + ).filter( + Operation.scheduled_operation_id == self.id, + Operation.confirmed == false() + ).delete() + + # 2) schedule remaining operations. + start_date = arrow.get(self.start_date) + stop_date = arrow.get(self.stop_date) + + for r in arrow.Arrow.range( + "month", start_date, stop_date + )[::self.frequency]: + current_monthrange = monthrange(r.year, r.month) + + day = max(self.day, current_monthrange[0]) + day = min(self.day, current_monthrange[1]) + + r.replace(day=day) + + # Search if a close operation exists. + if not session.query( + Operation + ).filter( + Operation.account_id == self.account_id, + Operation.scheduled_operation_id == self.id, + Operation.operation_date >= r.clone().replace(weeks=-2).date(), + Operation.operation_date <= r.clone().replace(weeks=+2).date() + ).count(): + # Create not confirmed operation if not found. + operation = Operation( + operation_date=r.date(), + label=self.label, + value=self.value, + category=self.category, + account_id=self.account_id, + scheduled_operation_id=self.id, + pointed=False, + confirmed=False, + canceled=False + ) + + session.add(operation) diff --git a/accountant/api/views/entries.py b/accountant/api/views/entries.py index 4c04c31..7070ec0 100644 --- a/accountant/api/views/entries.py +++ b/accountant/api/views/entries.py @@ -24,7 +24,6 @@ from accountant import session_aware from .. import api_api -from ..models.entries import Entry from ..models.operations import Operation from ..fields import Object @@ -40,6 +39,7 @@ resource_fields = { 'account_id': fields.Integer, 'scheduled_operation_id': fields.Integer(default=None), 'sold': fields.Float, + 'confirmed': fields.Boolean, 'canceled': fields.Boolean, } @@ -53,6 +53,8 @@ parser.add_argument('pointed', type=bool) parser.add_argument('category', type=str) parser.add_argument('account_id', type=int) parser.add_argument('scheduled_operation_id', type=int) +parser.add_argument('confirmed', type=bool) +parser.add_argument('canceled', type=bool) range_parser = reqparse.RequestParser() @@ -61,7 +63,7 @@ range_parser.add_argument('begin', type=lambda a: dateutil.parser.parse(a)) range_parser.add_argument('end', type=lambda a: dateutil.parser.parse(a)) -class EntryListResource(Resource): +class OperationListResource(Resource): @session_aware @marshal_with_field(fields.List(Object(resource_fields))) def get(self, session): @@ -77,57 +79,57 @@ class EntryListResource(Resource): def post(self, session): kwargs = parser.parse_args() - entry = Entry(**kwargs) + operation = Operation(**kwargs) - session.add(entry) + session.add(operation) - return entry + return operation -class EntryResource(Resource): +class OperationResource(Resource): @session_aware @marshal_with_field(Object(resource_fields)) - def get(self, entry_id, session): + def get(self, operation_id, session): """ - Get entry. + Get operation. """ try: - return Entry.get(session, entry_id) + return Operation.get(session, operation_id) except NoResultFound: return None, 404 @session_aware @marshal_with_field(Object(resource_fields)) - def post(self, entry_id, session): + def post(self, operation_id, session): kwargs = parser.parse_args() assert (id not in kwargs or kwargs.id is None - or kwargs.id == entry_id) + or kwargs.id == operation_id) try: - entry = Entry.get(session, entry_id) + operation = Operation.get(session, operation_id) except NoResultFound: return None, 404 # SQLAlchemy objects ignore __dict__.update() with merge. for k, v in kwargs.items(): - setattr(entry, k, v) + setattr(operation, k, v) - session.merge(entry) + session.merge(operation) - return entry + return operation @session_aware @marshal_with_field(Object(resource_fields)) - def delete(self, entry_id, session): + def delete(self, operation_id, session): try: - entry = Entry.get(session, entry_id) + operation = Operation.get(session, operation_id) except NoResultFound: return None, 404 - session.delete(entry) + session.delete(operation) - return entry + return operation category_resource_fields = { @@ -164,7 +166,7 @@ class SoldsResource(Resource): return Operation.get_ohlc_per_day_for_range(session, **kwargs).all() -api_api.add_resource(EntryListResource, "/entries") -api_api.add_resource(EntryResource, "/entries/") +api_api.add_resource(OperationListResource, "/entries") +api_api.add_resource(OperationResource, "/entries/") api_api.add_resource(CategoriesResource, "/categories") api_api.add_resource(SoldsResource, "/solds") diff --git a/accountant/api/views/scheduled_operations.py b/accountant/api/views/scheduled_operations.py index 28f3990..d8d8e44 100644 --- a/accountant/api/views/scheduled_operations.py +++ b/accountant/api/views/scheduled_operations.py @@ -80,11 +80,13 @@ class ScheduledOperationListResource(Resource): """ kwargs = parser.parse_args() - scheduledOperation = ScheduledOperation(**kwargs) + scheduled_operation = ScheduledOperation(**kwargs) - session.add(scheduledOperation) + session.add(scheduled_operation) - return scheduledOperation, 201 + scheduled_operation.reschedule(session) + + return scheduled_operation, 201 class ScheduledOperationResource(Resource): @@ -109,6 +111,9 @@ class ScheduledOperationResource(Resource): """ Delete a scheduled operation. """ + + raise NotImplementedError("Must be fixed.") + try: scheduled_operation = ScheduledOperation.query( session @@ -148,6 +153,8 @@ class ScheduledOperationResource(Resource): session.merge(scheduled_operation) + scheduled_operation.reschedule(session) + return scheduled_operation diff --git a/accountant/frontend/static/js/entries.js b/accountant/frontend/static/js/entries.js index 9f73eea..812c0de 100644 --- a/accountant/frontend/static/js/entries.js +++ b/accountant/frontend/static/js/entries.js @@ -396,7 +396,7 @@ accountantApp // Returns true if the entry is a scheduled one. $scope.isSaved = function(entry) { - return entry.id !== null; + return entry.confirmed; }; // Cancel current editing entry or clears field if a new one. @@ -426,11 +426,19 @@ accountantApp // Points an entry. $scope.pointEntry = function(entry) { + entry.confirmed = true; entry.pointed = !entry.pointed; $scope.saveEntry(entry); }; + // Confirm an entry. + $scope.confirmEntry = function(entry) { + entry.confirmed = true; + + $scope.saveEntry(entry); + }; + // Create an new entry. $scope.createEntry = function(entry) { entry.account_id = $scope.account.id; diff --git a/accountant/frontend/static/templates/entries.html b/accountant/frontend/static/templates/entries.html index 6955b30..24ddcc3 100644 --- a/accountant/frontend/static/templates/entries.html +++ b/accountant/frontend/static/templates/entries.html @@ -171,7 +171,7 @@
- diff --git a/migrations/versions/144929e0f5f_improve_operation_scheduling.py b/migrations/versions/144929e0f5f_improve_operation_scheduling.py new file mode 100644 index 0000000..b868602 --- /dev/null +++ b/migrations/versions/144929e0f5f_improve_operation_scheduling.py @@ -0,0 +1,144 @@ +"""Improve operation scheduling. + +Revision ID: 144929e0f5f +Revises: None +Create Date: 2015-07-17 15:04:01.002581 + +""" + +# revision identifiers, used by Alembic. +revision = '144929e0f5f' +down_revision = None + +from alembic import op +import sqlalchemy as sa + +from accountant import session_scope +from accountant.api.models.scheduled_operations import ScheduledOperation + + +def upgrade(): + op.get_bind().execute("DROP VIEW operation") + op.rename_table('entry', 'operation') + + # Add column "canceled" in table "entry" + op.add_column( + 'operation', + sa.Column( + 'canceled', + sa.Boolean(), + nullable=False, + default=False, + server_default=sa.false() + ) + ) + + # Add column "confirmed" in table "entry" + op.add_column( + 'operation', + sa.Column( + 'confirmed', + sa.Boolean(), + nullable=False, + default=True, + server_default=sa.true() + ) + ) + + # Drop unused table canceled_operation. + op.drop_table('canceled_operation') + + op.get_bind().execute( + "alter sequence entry_id_seq rename to operation_id_seq" + ) + + # TODO Alexis Lahouze 2015-07-17 Insert scheduling. + connection = op.get_bind() + Session = sa.orm.sessionmaker() + session = Session(bind=connection) + + # Get all scheduled operations + scheduled_operations = ScheduledOperation.query(session).all() + + for scheduled_operation in scheduled_operations: + scheduled_operation.reschedule(session) + + +def downgrade(): + op.get_bind().execute( + "alter sequence operation_id_seq rename to entry_id_seq" + ) + + op.create_table( + "canceled_operation", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "scheduled_operation_id", sa.Integer(), + sa.ForeignKey("scheduled_operation.id")), + sa.Column("operation_date", sa.Date, nullable=False) + ) + + op.drop_column('operation', 'canceled') + op.drop_column('operation', 'confirmed') + + op.rename_table('operation', 'entry') + + op.get_bind().execute( + """ +CREATE VIEW operation AS +SELECT entry.id, + entry.operation_date, + entry.label, + entry.value, + entry.account_id, + entry.category, + entry.pointed, + entry.scheduled_operation_id, + false AS canceled +FROM entry +UNION +SELECT NULL::bigint AS id, + scheduled_operation.operation_date, + scheduled_operation.label, + scheduled_operation.value, + scheduled_operation.account_id, + scheduled_operation.category, + false AS pointed, + scheduled_operation.id AS scheduled_operation_id, + false AS canceled +FROM ( + SELECT scheduled_operation_1.id, + scheduled_operation_1.start_date, + scheduled_operation_1.stop_date, + scheduled_operation_1.day, + scheduled_operation_1.frequency, + scheduled_operation_1.label, + scheduled_operation_1.value, + scheduled_operation_1.account_id, + scheduled_operation_1.category, + generate_series(scheduled_operation_1.start_date::timestamp without time zone, scheduled_operation_1.stop_date::timestamp without time zone, scheduled_operation_1.frequency::double precision * '1 mon'::interval) AS operation_date + FROM scheduled_operation scheduled_operation_1) scheduled_operation + LEFT JOIN ( + SELECT entry.scheduled_operation_id, + date_trunc('MONTH'::text, entry.operation_date::timestamp with time zone) AS operation_date + FROM entry + UNION + SELECT canceled_operation.scheduled_operation_id, + date_trunc('MONTH'::text, canceled_operation.operation_date::timestamp with time zone) AS operation_date + FROM canceled_operation + ) saved_operation ON saved_operation.scheduled_operation_id = scheduled_operation.id AND saved_operation.operation_date = date_trunc('MONTH'::text, scheduled_operation.operation_date) +WHERE saved_operation.scheduled_operation_id IS NULL +UNION +SELECT NULL::bigint AS id, + canceled_operation.operation_date, + scheduled_operation.label, + scheduled_operation.value, + scheduled_operation.account_id, + scheduled_operation.category, + false AS pointed, + scheduled_operation.id AS scheduled_operation_id, + true AS canceled +FROM scheduled_operation +JOIN canceled_operation ON canceled_operation.scheduled_operation_id = scheduled_operation.id; + """ + )