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
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):

View File

@ -14,28 +14,42 @@
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 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)

View File

@ -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/<int:entry_id>")
api_api.add_resource(OperationListResource, "/entries")
api_api.add_resource(OperationResource, "/entries/<int:operation_id>")
api_api.add_resource(CategoriesResource, "/categories")
api_api.add_resource(SoldsResource, "/solds")

View File

@ -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

View File

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

View File

@ -171,7 +171,7 @@
<!-- Button group for an unsaved (scheduled) 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>
</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;
"""
)