Merge scheduling and entries in operation.

This commit is contained in:
Alexis Lahouze 2015-07-17 19:22:04 +02:00
parent d9c1052f5e
commit c8b4c54742
8 changed files with 297 additions and 112 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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()

View File

@ -16,36 +16,68 @@
""" """
import arrow import arrow
from sqlalchemy import func, case, desc, false from sqlalchemy import func, case, desc, true, false
from accountant import db from accountant import db
class Operation(db.Model): class Operation(db.Model):
id = db.Column(db.Integer, primary_key=True) 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) operation_date = db.Column(db.Date, nullable=False)
label = db.Column(db.String(500), nullable=False) label = db.Column(db.String(500), nullable=False)
value = db.Column(db.Numeric(15, 2), 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', scheduled_operation_id = db.Column(
lazy="dynamic")) 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) 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, confirmed = db.Column(
category=None): db.Boolean,
self.pointed = pointed 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.operation_date = operation_date
self.label = label self.label = label
self.value = value self.value = value
self.account_id = account_id
self.category = category 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 @classmethod
def get_for_account_and_range(cls, session, account, begin, end): def get_for_account_and_range(cls, session, account, begin, end):

View File

@ -14,28 +14,42 @@
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with Accountant. If not, see <http://www.gnu.org/licenses/>. along with Accountant. If not, see <http://www.gnu.org/licenses/>.
""" """
from sqlalchemy import desc from calendar import monthrange
from sqlalchemy import desc, false
import arrow
from accountant import db from accountant import db
from .accounts import Account from .accounts import Account
from .operations import Operation
class ScheduledOperation(db.Model): class ScheduledOperation(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
start_date = db.Column(db.Date, nullable=False) start_date = db.Column(db.Date, nullable=False)
stop_date = db.Column(db.Date, nullable=False) stop_date = db.Column(db.Date, nullable=False)
day = db.Column(db.Integer, nullable=False) day = db.Column(db.Integer, nullable=False)
frequency = db.Column(db.Integer, nullable=False) frequency = db.Column(db.Integer, nullable=False)
label = db.Column(db.String(500), nullable=False) label = db.Column(db.String(500), nullable=False)
value = db.Column(db.Numeric(15, 2), 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) 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, def __init__(self, start_date, stop_date, day, frequency, label, value,
account_id, category=None): account_id, category=None):
self.start_date = start_date self.start_date = start_date
@ -56,3 +70,50 @@ class ScheduledOperation(db.Model):
cls.value, cls.value,
cls.label, 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)

View File

@ -24,7 +24,6 @@ from accountant import session_aware
from .. import api_api from .. import api_api
from ..models.entries import Entry
from ..models.operations import Operation from ..models.operations import Operation
from ..fields import Object from ..fields import Object
@ -40,6 +39,7 @@ resource_fields = {
'account_id': fields.Integer, 'account_id': fields.Integer,
'scheduled_operation_id': fields.Integer(default=None), 'scheduled_operation_id': fields.Integer(default=None),
'sold': fields.Float, 'sold': fields.Float,
'confirmed': fields.Boolean,
'canceled': fields.Boolean, 'canceled': fields.Boolean,
} }
@ -53,6 +53,8 @@ parser.add_argument('pointed', type=bool)
parser.add_argument('category', type=str) parser.add_argument('category', type=str)
parser.add_argument('account_id', type=int) parser.add_argument('account_id', type=int)
parser.add_argument('scheduled_operation_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() 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)) range_parser.add_argument('end', type=lambda a: dateutil.parser.parse(a))
class EntryListResource(Resource): class OperationListResource(Resource):
@session_aware @session_aware
@marshal_with_field(fields.List(Object(resource_fields))) @marshal_with_field(fields.List(Object(resource_fields)))
def get(self, session): def get(self, session):
@ -77,57 +79,57 @@ class EntryListResource(Resource):
def post(self, session): def post(self, session):
kwargs = parser.parse_args() 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 @session_aware
@marshal_with_field(Object(resource_fields)) @marshal_with_field(Object(resource_fields))
def get(self, entry_id, session): def get(self, operation_id, session):
""" """
Get entry. Get operation.
""" """
try: try:
return Entry.get(session, entry_id) return Operation.get(session, operation_id)
except NoResultFound: except NoResultFound:
return None, 404 return None, 404
@session_aware @session_aware
@marshal_with_field(Object(resource_fields)) @marshal_with_field(Object(resource_fields))
def post(self, entry_id, session): def post(self, operation_id, session):
kwargs = parser.parse_args() kwargs = parser.parse_args()
assert (id not in kwargs or kwargs.id is None assert (id not in kwargs or kwargs.id is None
or kwargs.id == entry_id) or kwargs.id == operation_id)
try: try:
entry = Entry.get(session, entry_id) operation = Operation.get(session, operation_id)
except NoResultFound: except NoResultFound:
return None, 404 return None, 404
# SQLAlchemy objects ignore __dict__.update() with merge. # SQLAlchemy objects ignore __dict__.update() with merge.
for k, v in kwargs.items(): 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 @session_aware
@marshal_with_field(Object(resource_fields)) @marshal_with_field(Object(resource_fields))
def delete(self, entry_id, session): def delete(self, operation_id, session):
try: try:
entry = Entry.get(session, entry_id) operation = Operation.get(session, operation_id)
except NoResultFound: except NoResultFound:
return None, 404 return None, 404
session.delete(entry) session.delete(operation)
return entry return operation
category_resource_fields = { category_resource_fields = {
@ -164,7 +166,7 @@ class SoldsResource(Resource):
return Operation.get_ohlc_per_day_for_range(session, **kwargs).all() return Operation.get_ohlc_per_day_for_range(session, **kwargs).all()
api_api.add_resource(EntryListResource, "/entries") api_api.add_resource(OperationListResource, "/entries")
api_api.add_resource(EntryResource, "/entries/<int:entry_id>") api_api.add_resource(OperationResource, "/entries/<int:operation_id>")
api_api.add_resource(CategoriesResource, "/categories") api_api.add_resource(CategoriesResource, "/categories")
api_api.add_resource(SoldsResource, "/solds") api_api.add_resource(SoldsResource, "/solds")

View File

@ -80,11 +80,13 @@ class ScheduledOperationListResource(Resource):
""" """
kwargs = parser.parse_args() 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): class ScheduledOperationResource(Resource):
@ -109,6 +111,9 @@ class ScheduledOperationResource(Resource):
""" """
Delete a scheduled operation. Delete a scheduled operation.
""" """
raise NotImplementedError("Must be fixed.")
try: try:
scheduled_operation = ScheduledOperation.query( scheduled_operation = ScheduledOperation.query(
session session
@ -148,6 +153,8 @@ class ScheduledOperationResource(Resource):
session.merge(scheduled_operation) session.merge(scheduled_operation)
scheduled_operation.reschedule(session)
return scheduled_operation return scheduled_operation

View File

@ -396,7 +396,7 @@ accountantApp
// Returns true if the entry is a scheduled one. // Returns true if the entry is a scheduled one.
$scope.isSaved = function(entry) { $scope.isSaved = function(entry) {
return entry.id !== null; return entry.confirmed;
}; };
// Cancel current editing entry or clears field if a new one. // Cancel current editing entry or clears field if a new one.
@ -426,11 +426,19 @@ accountantApp
// Points an entry. // Points an entry.
$scope.pointEntry = function(entry) { $scope.pointEntry = function(entry) {
entry.confirmed = true;
entry.pointed = !entry.pointed; entry.pointed = !entry.pointed;
$scope.saveEntry(entry); $scope.saveEntry(entry);
}; };
// Confirm an entry.
$scope.confirmEntry = function(entry) {
entry.confirmed = true;
$scope.saveEntry(entry);
};
// Create an new entry. // Create an new entry.
$scope.createEntry = function(entry) { $scope.createEntry = function(entry) {
entry.account_id = $scope.account.id; entry.account_id = $scope.account.id;

View File

@ -171,7 +171,7 @@
<!-- Button group for an unsaved (scheduled) entry. --> <!-- Button group for an unsaved (scheduled) entry. -->
<div class="btn-group" ng-if="!isSaved(entry)"> <div class="btn-group" ng-if="!isSaved(entry)">
<button class="btn btn-xs btn-success" ng-click="saveEntry(entry)" title="Save"> <button class="btn btn-xs btn-success" ng-click="confirmEntry(entry)" title="Save">
<span class="fa fa-plus"></span> <span class="fa fa-plus"></span>
</button> </button>

View File

@ -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;
"""
)