Merge scheduling and entries in operation.
This commit is contained in:
parent
d9c1052f5e
commit
c8b4c54742
@ -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()
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
||||
|
144
migrations/versions/144929e0f5f_improve_operation_scheduling.py
Normal file
144
migrations/versions/144929e0f5f_improve_operation_scheduling.py
Normal 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;
|
||||
"""
|
||||
)
|
Loading…
Reference in New Issue
Block a user