Use black to refomat the files.

This commit is contained in:
Alexis M 2019-10-11 20:20:13 +02:00 committed by Alexis Metaireau
parent f2a0b9f3f0
commit f260a2c9e7
17 changed files with 1479 additions and 1075 deletions

View file

@ -5,11 +5,10 @@ templates_path = ["_templates"]
source_suffix = ".rst"
master_doc = "index"
project = u"I hate money"
copyright = u"2011, The 'I hate money' team"
project = "I hate money"
copyright = "2011, The 'I hate money' team"
version = "1.0"
release = "1.0"
exclude_patterns = ["_build"]
pygments_style = "sphinx"

View file

@ -5,8 +5,7 @@ from flask_cors import CORS
from wtforms.fields.core import BooleanField
from ihatemoney.models import db, Project, Person, Bill
from ihatemoney.forms import (ProjectForm, EditProjectForm, MemberForm,
get_billform_for)
from ihatemoney.forms import ProjectForm, EditProjectForm, MemberForm, get_billform_for
from werkzeug.security import check_password_hash
from functools import wraps
@ -21,6 +20,7 @@ def need_auth(f):
Return the project if the authorization is good, abort the request with a 401 otherwise
"""
@wraps(f)
def wrapper(*args, **kwargs):
auth = request.authorization
@ -35,25 +35,26 @@ def need_auth(f):
return f(*args, project=project, **kwargs)
else:
# Use Bearer token Auth
auth_header = request.headers.get('Authorization', '')
auth_token = ''
auth_header = request.headers.get("Authorization", "")
auth_token = ""
try:
auth_token = auth_header.split(" ")[1]
except IndexError:
abort(401)
project_id = Project.verify_token(auth_token, token_type='non_timed_token')
project_id = Project.verify_token(auth_token, token_type="non_timed_token")
if auth_token and project_id:
project = Project.query.get(project_id)
if project:
kwargs.pop("project_id")
return f(*args, project=project, **kwargs)
abort(401)
return wrapper
class ProjectsHandler(Resource):
def post(self):
form = ProjectForm(meta={'csrf': False})
form = ProjectForm(meta={"csrf": False})
if form.validate():
project = form.save()
db.session.add(project)
@ -74,7 +75,7 @@ class ProjectHandler(Resource):
return "DELETED"
def put(self, project):
form = EditProjectForm(meta={'csrf': False})
form = EditProjectForm(meta={"csrf": False})
if form.validate():
form.update(project)
db.session.commit()
@ -94,7 +95,8 @@ class APIMemberForm(MemberForm):
But we want Member.enabled to be togglable via the API.
"""
activated = BooleanField(false_values=('false', '', 'False'))
activated = BooleanField(false_values=("false", "", "False"))
def save(self, project, person):
person.activated = self.activated.data
@ -108,7 +110,7 @@ class MembersHandler(Resource):
return project.members
def post(self, project):
form = MemberForm(project, meta={'csrf': False})
form = MemberForm(project, meta={"csrf": False})
if form.validate():
member = Person()
form.save(project, member)
@ -127,7 +129,7 @@ class MemberHandler(Resource):
return member
def put(self, project, member_id):
form = APIMemberForm(project, meta={'csrf': False}, edit=True)
form = APIMemberForm(project, meta={"csrf": False}, edit=True)
if form.validate():
member = Person.query.get(member_id, project)
form.save(project, member)
@ -148,7 +150,7 @@ class BillsHandler(Resource):
return project.get_bills().all()
def post(self, project):
form = get_billform_for(project, True, meta={'csrf': False})
form = get_billform_for(project, True, meta={"csrf": False})
if form.validate():
bill = Bill()
form.save(bill, project)
@ -168,7 +170,7 @@ class BillHandler(Resource):
return bill, 200
def put(self, project, bill_id):
form = get_billform_for(project, True, meta={'csrf': False})
form = get_billform_for(project, True, meta={"csrf": False})
if form.validate():
bill = Bill.query.get(project, bill_id)
form.save(bill, project)
@ -184,10 +186,16 @@ class BillHandler(Resource):
return "OK", 200
restful_api.add_resource(ProjectsHandler, '/projects')
restful_api.add_resource(ProjectHandler, '/projects/<string:project_id>')
restful_api.add_resource(ProjectsHandler, "/projects")
restful_api.add_resource(ProjectHandler, "/projects/<string:project_id>")
restful_api.add_resource(MembersHandler, "/projects/<string:project_id>/members")
restful_api.add_resource(ProjectStatsHandler, "/projects/<string:project_id>/statistics")
restful_api.add_resource(MemberHandler, "/projects/<string:project_id>/members/<int:member_id>")
restful_api.add_resource(
ProjectStatsHandler, "/projects/<string:project_id>/statistics"
)
restful_api.add_resource(
MemberHandler, "/projects/<string:project_id>/members/<int:member_id>"
)
restful_api.add_resource(BillsHandler, "/projects/<string:project_id>/bills")
restful_api.add_resource(BillHandler, "/projects/<string:project_id>/bills/<int:bill_id>")
restful_api.add_resource(
BillHandler, "/projects/<string:project_id>/bills/<int:bill_id>"
)

View file

@ -1,6 +1,6 @@
# Verbose and documented settings are in conf-templates/ihatemoney.cfg.j2
DEBUG = SQLACHEMY_ECHO = False
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/ihatemoney.db'
SQLALCHEMY_DATABASE_URI = "sqlite:////tmp/ihatemoney.db"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SECRET_KEY = "tralala"
MAIL_DEFAULT_SENDER = ("Budget manager", "budget@notmyidea.org")
@ -8,4 +8,4 @@ ACTIVATE_DEMO_PROJECT = True
ADMIN_PASSWORD = ""
ALLOW_PUBLIC_PROJECT_CREATION = True
ACTIVATE_ADMIN_DASHBOARD = False
SUPPORTED_LANGUAGES = ['en', 'fr', 'de', 'nl', 'es_419']
SUPPORTED_LANGUAGES = ["en", "fr", "de", "nl", "es_419"]

View file

@ -2,7 +2,14 @@ from flask_wtf.form import FlaskForm
from wtforms.fields.core import SelectField, SelectMultipleField
from wtforms.fields.html5 import DateField, DecimalField, URLField
from wtforms.fields.simple import PasswordField, SubmitField, TextAreaField, StringField
from wtforms.validators import Email, DataRequired, ValidationError, EqualTo, NumberRange, Optional
from wtforms.validators import (
Email,
DataRequired,
ValidationError,
EqualTo,
NumberRange,
Optional,
)
from flask_babel import lazy_gettext as _
from flask import request
from werkzeug.security import generate_password_hash
@ -48,7 +55,7 @@ class CommaDecimalField(DecimalField):
def process_formdata(self, value):
if value:
value[0] = str(value[0]).replace(',', '.')
value[0] = str(value[0]).replace(",", ".")
return super(CommaDecimalField, self).process_formdata(value)
@ -68,7 +75,7 @@ class CalculatorStringField(StringField):
value = str(valuelist[0]).replace(",", ".")
# avoid exponents to prevent expensive calculations i.e 2**9999999999**9999999
if not match(r'^[ 0-9\.\+\-\*/\(\)]{0,200}$', value) or "**" in value:
if not match(r"^[ 0-9\.\+\-\*/\(\)]{0,200}$", value) or "**" in value:
raise ValueError(Markup(message))
valuelist[0] = str(eval_arithmetic_expression(value))
@ -86,9 +93,12 @@ class EditProjectForm(FlaskForm):
Returns the created instance
"""
project = Project(name=self.name.data, id=self.id.data,
password=generate_password_hash(self.password.data),
contact_email=self.contact_email.data)
project = Project(
name=self.name.data,
id=self.id.data,
password=generate_password_hash(self.password.data),
contact_email=self.contact_email.data,
)
return project
def update(self, project):
@ -108,8 +118,11 @@ class ProjectForm(EditProjectForm):
def validate_id(form, field):
form.id.data = slugify(field.data)
if (form.id.data == "dashboard") or Project.query.get(form.id.data):
message = _("A project with this identifier (\"%(project)s\") already exists. "
"Please choose a new identifier", project=form.id.data)
message = _(
'A project with this identifier ("%(project)s") already exists. '
"Please choose a new identifier",
project=form.id.data,
)
raise ValidationError(Markup(message))
@ -134,10 +147,14 @@ class PasswordReminder(FlaskForm):
class ResetPasswordForm(FlaskForm):
password_validators = [DataRequired(),
EqualTo('password_confirmation', message=_("Password mismatch"))]
password_validators = [
DataRequired(),
EqualTo("password_confirmation", message=_("Password mismatch")),
]
password = PasswordField(_("Password"), validators=password_validators)
password_confirmation = PasswordField(_("Password confirmation"), validators=[DataRequired()])
password_confirmation = PasswordField(
_("Password confirmation"), validators=[DataRequired()]
)
submit = SubmitField(_("Reset password"))
@ -146,10 +163,14 @@ class BillForm(FlaskForm):
what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()])
external_link = URLField(_("External link"), validators=[Optional(
)], description=_("A link to an external document, related to this bill"))
payed_for = SelectMultipleField(_("For whom?"),
validators=[DataRequired()], coerce=int)
external_link = URLField(
_("External link"),
validators=[Optional()],
description=_("A link to an external document, related to this bill"),
)
payed_for = SelectMultipleField(
_("For whom?"), validators=[DataRequired()], coerce=int
)
submit = SubmitField(_("Submit"))
submit2 = SubmitField(_("Submit and add a new one"))
@ -159,8 +180,7 @@ class BillForm(FlaskForm):
bill.what = self.what.data
bill.external_link = self.external_link.data
bill.date = self.date.data
bill.owers = [Person.query.get(ower, project)
for ower in self.payed_for.data]
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data]
return bill
@ -181,11 +201,10 @@ class BillForm(FlaskForm):
class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter, ])
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter])
weight_validators = [NumberRange(min=0.1, message=_("Weights should be positive"))]
weight = CommaDecimalField(_("Weight"), default=1,
validators=weight_validators)
weight = CommaDecimalField(_("Weight"), default=1, validators=weight_validators)
submit = SubmitField(_("Add"))
def __init__(self, project, edit=False, *args, **kwargs):
@ -196,10 +215,14 @@ class MemberForm(FlaskForm):
def validate_name(form, field):
if field.data == form.name.default:
raise ValidationError(_("User name incorrect"))
if (not form.edit and Person.query.filter(
if (
not form.edit
and Person.query.filter(
Person.name == field.data,
Person.project == form.project,
Person.activated == True).all()): # NOQA
Person.activated == True,
).all()
): # NOQA
raise ValidationError(_("This project already have this member"))
def save(self, project, person):
@ -224,5 +247,6 @@ class InviteForm(FlaskForm):
try:
email_validator.validate_email(email)
except email_validator.EmailNotValidError:
raise ValidationError(_("The email %(email)s is not valid",
email=email))
raise ValidationError(
_("The email %(email)s is not valid", email=email)
)

View file

@ -19,42 +19,51 @@ class GeneratePasswordHash(Command):
"""Get password from user and hash it without printing it in clear text."""
def run(self):
password = getpass.getpass(prompt='Password: ')
password = getpass.getpass(prompt="Password: ")
print(generate_password_hash(password))
class GenerateConfig(Command):
def get_options(self):
return [
Option('config_file', choices=[
'ihatemoney.cfg',
'apache-vhost.conf',
'gunicorn.conf.py',
'supervisord.conf',
'nginx.conf',
]),
Option(
"config_file",
choices=[
"ihatemoney.cfg",
"apache-vhost.conf",
"gunicorn.conf.py",
"supervisord.conf",
"nginx.conf",
],
)
]
@staticmethod
def gen_secret_key():
return ''.join([
random.SystemRandom().choice(
'abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)')
for i in range(50)])
return "".join(
[
random.SystemRandom().choice(
"abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)"
)
for i in range(50)
]
)
def run(self, config_file):
env = create_jinja_env('conf-templates', strict_rendering=True)
template = env.get_template('%s.j2' % config_file)
env = create_jinja_env("conf-templates", strict_rendering=True)
template = env.get_template("%s.j2" % config_file)
bin_path = os.path.dirname(sys.executable)
pkg_path = os.path.abspath(os.path.dirname(__file__))
print(template.render(
print(
template.render(
pkg_path=pkg_path,
bin_path=bin_path,
sys_prefix=sys.prefix,
secret_key=self.gen_secret_key(),
))
)
)
class DeleteProject(Command):
@ -65,14 +74,14 @@ class DeleteProject(Command):
def main():
QUIET_COMMANDS = ('generate_password_hash', 'generate-config')
QUIET_COMMANDS = ("generate_password_hash", "generate-config")
exception = None
backup_stderr = sys.stderr
# Hack to divert stderr for commands generating content to stdout
# to avoid confusing the user
if len(sys.argv) > 1 and sys.argv[1] in QUIET_COMMANDS:
sys.stderr = open(os.devnull, 'w')
sys.stderr = open(os.devnull, "w")
try:
app = create_app()
@ -87,12 +96,12 @@ def main():
raise exception
manager = Manager(app)
manager.add_command('db', MigrateCommand)
manager.add_command('generate_password_hash', GeneratePasswordHash)
manager.add_command('generate-config', GenerateConfig)
manager.add_command('delete-project', DeleteProject)
manager.add_command("db", MigrateCommand)
manager.add_command("generate_password_hash", GeneratePasswordHash)
manager.add_command("generate-config", GenerateConfig)
manager.add_command("delete-project", DeleteProject)
manager.run()
if __name__ == '__main__':
if __name__ == "__main__":
main()

View file

@ -11,14 +11,16 @@ config = context.config
# Interpret the config file for Python logging. This line sets up loggers
# basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
logger = logging.getLogger("alembic.env")
# Add your model's MetaData object here for 'autogenerate' support from myapp
# import mymodel target_metadata = mymodel.Base.metadata.
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata
config.set_main_option(
"sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI")
)
target_metadata = current_app.extensions["migrate"].db.metadata
# Other values from the config, defined by the needs of env.py,
# can be acquired:
@ -57,21 +59,25 @@ def run_migrations_online():
# when there are no changes to the schema.
# reference: https://alembic.readthedocs.io/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
logger.info("No changes in schema detected.")
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
engine = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions["migrate"].configure_args
)
try:
with context.begin_transaction():
@ -79,6 +85,7 @@ def run_migrations_online():
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:

View file

@ -7,8 +7,8 @@ Create Date: 2016-06-15 09:22:04.069447
"""
# revision identifiers, used by Alembic.
revision = '26d6a218c329'
down_revision = 'b9a10d5d63ce'
revision = "26d6a218c329"
down_revision = "b9a10d5d63ce"
from alembic import op
import sqlalchemy as sa
@ -16,11 +16,11 @@ import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('person', sa.Column('weight', sa.Float(), nullable=True))
op.add_column("person", sa.Column("weight", sa.Float(), nullable=True))
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('person', 'weight')
op.drop_column("person", "weight")
### end Alembic commands ###

View file

@ -7,8 +7,8 @@ Create Date: 2019-09-28 13:38:09.550747
"""
# revision identifiers, used by Alembic.
revision = '6c6fb2b7f229'
down_revision = 'a67119aa3ee5'
revision = "6c6fb2b7f229"
down_revision = "a67119aa3ee5"
from alembic import op
import sqlalchemy as sa
@ -16,11 +16,11 @@ import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('bill', sa.Column('external_link', sa.UnicodeText(), nullable=True))
op.add_column("bill", sa.Column("external_link", sa.UnicodeText(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('bill', 'external_link')
op.drop_column("bill", "external_link")
# ### end Alembic commands ###

View file

@ -7,29 +7,29 @@ Create Date: 2018-12-25 18:34:20.220844
"""
# revision identifiers, used by Alembic.
revision = 'a67119aa3ee5'
down_revision = 'afbf27e6ef20'
revision = "a67119aa3ee5"
down_revision = "afbf27e6ef20"
from alembic import op
import sqlalchemy as sa
# Snapshot of the person table
person_helper = sa.Table(
'person', sa.MetaData(),
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.Column('activated', sa.Boolean(), nullable=True),
sa.Column('weight', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
"person",
sa.MetaData(),
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.Column("activated", sa.Boolean(), nullable=True),
sa.Column("weight", sa.Float(), nullable=True),
sa.ForeignKeyConstraint(["project_id"], ["project.id"]),
sa.PrimaryKeyConstraint("id"),
)
def upgrade():
op.execute(
person_helper.update()
.where(person_helper.c.weight <= 0)
.values(weight=1)
person_helper.update().where(person_helper.c.weight <= 0).values(weight=1)
)

View file

@ -7,8 +7,8 @@ Create Date: 2018-02-19 20:29:26.286136
"""
# revision identifiers, used by Alembic.
revision = 'afbf27e6ef20'
down_revision = 'b78f8a8bdb16'
revision = "afbf27e6ef20"
down_revision = "b78f8a8bdb16"
from alembic import op
import sqlalchemy as sa
@ -16,11 +16,11 @@ import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column('bill', sa.Column('creation_date', sa.Date(), nullable=True))
op.add_column("bill", sa.Column("creation_date", sa.Date(), nullable=True))
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column('bill', 'creation_date')
op.drop_column("bill", "creation_date")
### end Alembic commands ###

View file

@ -7,20 +7,21 @@ Create Date: 2017-12-17 11:45:44.783238
"""
# revision identifiers, used by Alembic.
revision = 'b78f8a8bdb16'
down_revision = 'f629c8ef4ab0'
revision = "b78f8a8bdb16"
down_revision = "f629c8ef4ab0"
from alembic import op
import sqlalchemy as sa
from werkzeug.security import generate_password_hash
project_helper = sa.Table(
'project', sa.MetaData(),
sa.Column('id', sa.String(length=64), nullable=False),
sa.Column('name', sa.UnicodeText(), nullable=True),
sa.Column('password', sa.String(length=128), nullable=True),
sa.Column('contact_email', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id')
"project",
sa.MetaData(),
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("name", sa.UnicodeText(), nullable=True),
sa.Column("password", sa.String(length=128), nullable=True),
sa.Column("contact_email", sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
@ -28,11 +29,9 @@ def upgrade():
connection = op.get_bind()
for project in connection.execute(project_helper.select()):
connection.execute(
project_helper.update().where(
project_helper.c.name == project.name
).values(
password=generate_password_hash(project.password)
)
project_helper.update()
.where(project_helper.c.name == project.name)
.values(password=generate_password_hash(project.password))
)

View file

@ -7,7 +7,7 @@ Create Date: 2016-05-21 23:21:21.605076
"""
# revision identifiers, used by Alembic.
revision = 'b9a10d5d63ce'
revision = "b9a10d5d63ce"
down_revision = None
from alembic import op
@ -16,53 +16,58 @@ import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('project',
sa.Column('id', sa.String(length=64), nullable=False),
sa.Column('name', sa.UnicodeText(), nullable=True),
sa.Column('password', sa.String(length=128), nullable=True),
sa.Column('contact_email', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id')
op.create_table(
"project",
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("name", sa.UnicodeText(), nullable=True),
sa.Column("password", sa.String(length=128), nullable=True),
sa.Column("contact_email", sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table('archive',
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')
op.create_table(
"archive",
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"),
)
op.create_table('person',
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.Column('activated', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"person",
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.Column("activated", sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(["project_id"], ["project.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table('bill',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('payer_id', sa.Integer(), nullable=True),
sa.Column('amount', sa.Float(), nullable=True),
sa.Column('date', sa.Date(), nullable=True),
sa.Column('what', sa.UnicodeText(), nullable=True),
sa.Column('archive', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['archive'], ['archive.id'], ),
sa.ForeignKeyConstraint(['payer_id'], ['person.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"bill",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("payer_id", sa.Integer(), nullable=True),
sa.Column("amount", sa.Float(), nullable=True),
sa.Column("date", sa.Date(), nullable=True),
sa.Column("what", sa.UnicodeText(), nullable=True),
sa.Column("archive", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["archive"], ["archive.id"]),
sa.ForeignKeyConstraint(["payer_id"], ["person.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table('billowers',
sa.Column('bill_id', sa.Integer(), nullable=True),
sa.Column('person_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['bill_id'], ['bill.id'], ),
sa.ForeignKeyConstraint(['person_id'], ['person.id'], )
op.create_table(
"billowers",
sa.Column("bill_id", sa.Integer(), nullable=True),
sa.Column("person_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["bill_id"], ["bill.id"]),
sa.ForeignKeyConstraint(["person_id"], ["person.id"]),
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('billowers')
op.drop_table('bill')
op.drop_table('person')
op.drop_table('archive')
op.drop_table('project')
op.drop_table("billowers")
op.drop_table("bill")
op.drop_table("person")
op.drop_table("archive")
op.drop_table("project")
### end Alembic commands ###

View file

@ -7,30 +7,29 @@ Create Date: 2016-06-15 09:40:30.400862
"""
# revision identifiers, used by Alembic.
revision = 'f629c8ef4ab0'
down_revision = '26d6a218c329'
revision = "f629c8ef4ab0"
down_revision = "26d6a218c329"
from alembic import op
import sqlalchemy as sa
# Snapshot of the person table
person_helper = sa.Table(
'person', sa.MetaData(),
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.Column('activated', sa.Boolean(), nullable=True),
sa.Column('weight', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
"person",
sa.MetaData(),
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.Column("activated", sa.Boolean(), nullable=True),
sa.Column("weight", sa.Float(), nullable=True),
sa.ForeignKeyConstraint(["project_id"], ["project.id"]),
sa.PrimaryKeyConstraint("id"),
)
def upgrade():
op.execute(
person_helper.update()
.where(person_helper.c.weight == None)
.values(weight=1)
person_helper.update().where(person_helper.c.weight == None).values(weight=1)
)

View file

@ -6,8 +6,12 @@ from flask import g, current_app
from debts import settle
from sqlalchemy import orm
from itsdangerous import (TimedJSONWebSignatureSerializer, URLSafeSerializer,
BadSignature, SignatureExpired)
from itsdangerous import (
TimedJSONWebSignatureSerializer,
URLSafeSerializer,
BadSignature,
SignatureExpired,
)
db = SQLAlchemy()
@ -33,8 +37,8 @@ class Project(db.Model):
balance = self.balance
for member in self.members:
member_obj = member._to_serialize
member_obj['balance'] = balance.get(member.id, 0)
obj['members'].append(member_obj)
member_obj["balance"] = balance.get(member.id, 0)
obj["members"].append(member_obj)
return obj
@ -45,14 +49,14 @@ class Project(db.Model):
@property
def balance(self):
balances, should_pay, should_receive = (defaultdict(int)
for time in (1, 2, 3))
balances, should_pay, should_receive = (defaultdict(int) for time in (1, 2, 3))
# for each person
for person in self.members:
# get the list of bills he has to pay
bills = Bill.query.options(orm.subqueryload(Bill.owers)).filter(
Bill.owers.contains(person))
Bill.owers.contains(person)
)
for bill in bills.all():
if person != bill.payer:
share = bill.pay_each() * person.weight
@ -72,18 +76,23 @@ class Project(db.Model):
:return: one stat dict per member
:rtype list:
"""
return [{
'member': member,
'paid': sum([
bill.amount
for bill in self.get_member_bills(member.id).all()
]),
'spent': sum([
bill.pay_each() * member.weight
for bill in self.get_bills().all() if member in bill.owers
]),
'balance': self.balance[member.id]
} for member in self.active_members]
return [
{
"member": member,
"paid": sum(
[bill.amount for bill in self.get_member_bills(member.id).all()]
),
"spent": sum(
[
bill.pay_each() * member.weight
for bill in self.get_bills().all()
if member in bill.owers
]
),
"balance": self.balance[member.id],
}
for member in self.active_members
]
@property
def uses_weights(self):
@ -99,22 +108,27 @@ class Project(db.Model):
return transactions
pretty_transactions = []
for transaction in transactions:
pretty_transactions.append({
'ower': transaction['ower'].name,
'receiver': transaction['receiver'].name,
'amount': round(transaction['amount'], 2)
})
pretty_transactions.append(
{
"ower": transaction["ower"].name,
"receiver": transaction["receiver"].name,
"amount": round(transaction["amount"], 2),
}
)
return pretty_transactions
# cache value for better performance
members = {person.id: person for person in self.members}
settle_plan = settle(self.balance.items()) or []
transactions = [{
'ower': members[ower_id],
'receiver': members[receiver_id],
'amount': amount
} for ower_id, amount, receiver_id in settle_plan]
transactions = [
{
"ower": members[ower_id],
"receiver": members[receiver_id],
"amount": amount,
}
for ower_id, amount, receiver_id in settle_plan
]
return prettify(transactions, pretty_output)
@ -140,23 +154,27 @@ class Project(db.Model):
def get_bills(self):
"""Return the list of bills related to this project"""
return Bill.query.join(Person, Project)\
.filter(Bill.payer_id == Person.id)\
.filter(Person.project_id == Project.id)\
.filter(Project.id == self.id)\
.order_by(Bill.date.desc())\
.order_by(Bill.creation_date.desc())\
return (
Bill.query.join(Person, Project)
.filter(Bill.payer_id == Person.id)
.filter(Person.project_id == Project.id)
.filter(Project.id == self.id)
.order_by(Bill.date.desc())
.order_by(Bill.creation_date.desc())
.order_by(Bill.id.desc())
)
def get_member_bills(self, member_id):
"""Return the list of bills related to a specific member"""
return Bill.query.join(Person, Project)\
.filter(Bill.payer_id == Person.id)\
.filter(Person.project_id == Project.id)\
.filter(Person.id == member_id)\
.filter(Project.id == self.id)\
.order_by(Bill.date.desc())\
return (
Bill.query.join(Person, Project)
.filter(Bill.payer_id == Person.id)
.filter(Person.project_id == Project.id)
.filter(Person.id == member_id)
.filter(Project.id == self.id)
.order_by(Bill.date.desc())
.order_by(Bill.id.desc())
)
def get_pretty_bills(self, export_format="json"):
"""Return a list of project's bills with pretty formatting"""
@ -166,16 +184,18 @@ class Project(db.Model):
if export_format == "json":
owers = [ower.name for ower in bill.owers]
else:
owers = ', '.join([ower.name for ower in bill.owers])
owers = ", ".join([ower.name for ower in bill.owers])
pretty_bills.append({
"what": bill.what,
"amount": round(bill.amount, 2),
"date": str(bill.date),
"payer_name": Person.query.get(bill.payer_id).name,
"payer_weight": Person.query.get(bill.payer_id).weight,
"owers": owers
})
pretty_bills.append(
{
"what": bill.what,
"amount": round(bill.amount, 2),
"date": str(bill.date),
"payer_name": Person.query.get(bill.payer_id).name,
"payer_weight": Person.query.get(bill.payer_id).weight,
"owers": owers,
}
)
return pretty_bills
def remove_member(self, member_id):
@ -210,12 +230,12 @@ class Project(db.Model):
"""
if expiration:
serializer = TimedJSONWebSignatureSerializer(
current_app.config['SECRET_KEY'],
expiration)
token = serializer.dumps({'project_id': self.id}).decode('utf-8')
current_app.config["SECRET_KEY"], expiration
)
token = serializer.dumps({"project_id": self.id}).decode("utf-8")
else:
serializer = URLSafeSerializer(current_app.config['SECRET_KEY'])
token = serializer.dumps({'project_id': self.id})
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
token = serializer.dumps({"project_id": self.id})
return token
@staticmethod
@ -226,34 +246,40 @@ class Project(db.Model):
:param token: Serialized TimedJsonWebToken
"""
if token_type == "timed_token":
serializer = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
serializer = TimedJSONWebSignatureSerializer(
current_app.config["SECRET_KEY"]
)
else:
serializer = URLSafeSerializer(current_app.config['SECRET_KEY'])
serializer = URLSafeSerializer(current_app.config["SECRET_KEY"])
try:
data = serializer.loads(token)
except SignatureExpired:
return None
except BadSignature:
return None
return data['project_id']
return data["project_id"]
def __repr__(self):
return "<Project %s>" % self.name
class Person(db.Model):
class PersonQuery(BaseQuery):
def get_by_name(self, name, project):
return Person.query.filter(Person.name == name)\
.filter(Project.id == project.id).one()
return (
Person.query.filter(Person.name == name)
.filter(Project.id == project.id)
.one()
)
def get(self, id, project=None):
if not project:
project = g.project
return Person.query.filter(Person.id == id)\
.filter(Project.id == project.id).one()
return (
Person.query.filter(Person.id == id)
.filter(Project.id == project.id)
.one()
)
query_class = PersonQuery
@ -276,9 +302,11 @@ class Person(db.Model):
def has_bills(self):
"""return if the user do have bills or not"""
bills_as_ower_number = db.session.query(billowers)\
.filter(billowers.columns.get("person_id") == self.id)\
bills_as_ower_number = (
db.session.query(billowers)
.filter(billowers.columns.get("person_id") == self.id)
.count()
)
return bills_as_ower_number != 0 or len(self.bills) != 0
def __str__(self):
@ -290,23 +318,24 @@ class Person(db.Model):
# We need to manually define a join table for m2m relations
billowers = db.Table(
'billowers',
db.Column('bill_id', db.Integer, db.ForeignKey('bill.id')),
db.Column('person_id', db.Integer, db.ForeignKey('person.id')),
"billowers",
db.Column("bill_id", db.Integer, db.ForeignKey("bill.id")),
db.Column("person_id", db.Integer, db.ForeignKey("person.id")),
)
class Bill(db.Model):
class BillQuery(BaseQuery):
def get(self, project, id):
try:
return (self.join(Person, Project)
.filter(Bill.payer_id == Person.id)
.filter(Person.project_id == Project.id)
.filter(Project.id == project.id)
.filter(Bill.id == id).one())
return (
self.join(Person, Project)
.filter(Bill.payer_id == Person.id)
.filter(Person.project_id == Project.id)
.filter(Project.id == project.id)
.filter(Bill.id == id)
.one()
)
except orm.exc.NoResultFound:
return None
@ -356,7 +385,8 @@ class Bill(db.Model):
def __repr__(self):
return "<Bill of %s from %s for %s>" % (
self.amount,
self.payer, ", ".join([o.name for o in self.owers])
self.payer,
", ".join([o.name for o in self.owers]),
)

View file

@ -10,8 +10,13 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from ihatemoney.api import api
from ihatemoney.models import db
from ihatemoney.utils import (IhmJSONEncoder, PrefixedWSGI, locale_from_iso,
minimal_round, static_include)
from ihatemoney.utils import (
IhmJSONEncoder,
PrefixedWSGI,
locale_from_iso,
minimal_round,
static_include,
)
from ihatemoney.web import main as web_interface
from ihatemoney import default_settings
@ -24,27 +29,27 @@ def setup_database(app):
""" Checks if we are migrating from a pre-alembic ihatemoney
"""
con = db.engine.connect()
tables_exist = db.engine.dialect.has_table(con, 'project')
alembic_setup = db.engine.dialect.has_table(con, 'alembic_version')
tables_exist = db.engine.dialect.has_table(con, "project")
alembic_setup = db.engine.dialect.has_table(con, "alembic_version")
return tables_exist and not alembic_setup
sqlalchemy_url = app.config.get('SQLALCHEMY_DATABASE_URI')
if sqlalchemy_url.startswith('sqlite:////tmp'):
sqlalchemy_url = app.config.get("SQLALCHEMY_DATABASE_URI")
if sqlalchemy_url.startswith("sqlite:////tmp"):
warnings.warn(
'The database is currently stored in /tmp and might be lost at '
'next reboot.'
"The database is currently stored in /tmp and might be lost at "
"next reboot."
)
db.init_app(app)
db.app = app
Migrate(app, db)
migrations_path = os.path.join(app.root_path, 'migrations')
migrations_path = os.path.join(app.root_path, "migrations")
if _pre_alembic_db():
with app.app_context():
# fake the first migration
stamp(migrations_path, revision='b9a10d5d63ce')
stamp(migrations_path, revision="b9a10d5d63ce")
# auto-execute migrations on runtime
with app.app_context():
@ -60,38 +65,38 @@ def load_configuration(app, configuration=None):
- Otherwise, load the default settings.
"""
env_var_config = os.environ.get('IHATEMONEY_SETTINGS_FILE_PATH')
app.config.from_object('ihatemoney.default_settings')
env_var_config = os.environ.get("IHATEMONEY_SETTINGS_FILE_PATH")
app.config.from_object("ihatemoney.default_settings")
if configuration:
app.config.from_object(configuration)
elif env_var_config:
app.config.from_pyfile(env_var_config)
else:
app.config.from_pyfile('ihatemoney.cfg', silent=True)
app.config.from_pyfile("ihatemoney.cfg", silent=True)
# Configure custom JSONEncoder used by the API
app.config['RESTFUL_JSON'] = {'cls': IhmJSONEncoder}
app.config["RESTFUL_JSON"] = {"cls": IhmJSONEncoder}
def validate_configuration(app):
if app.config['SECRET_KEY'] == default_settings.SECRET_KEY:
if app.config["SECRET_KEY"] == default_settings.SECRET_KEY:
warnings.warn(
"Running a server without changing the SECRET_KEY can lead to"
+ " user impersonation. Please update your configuration file.",
UserWarning
UserWarning,
)
# Deprecations
if 'DEFAULT_MAIL_SENDER' in app.config:
if "DEFAULT_MAIL_SENDER" in app.config:
# Since flask-mail 0.8
warnings.warn(
"DEFAULT_MAIL_SENDER is deprecated in favor of MAIL_DEFAULT_SENDER"
+ " and will be removed in further version",
UserWarning
UserWarning,
)
if 'MAIL_DEFAULT_SENDER' not in app.config:
app.config['MAIL_DEFAULT_SENDER'] = default_settings.DEFAULT_MAIL_SENDER
if "MAIL_DEFAULT_SENDER" not in app.config:
app.config["MAIL_DEFAULT_SENDER"] = default_settings.DEFAULT_MAIL_SENDER
if "pbkdf2:" not in app.config['ADMIN_PASSWORD'] and app.config['ADMIN_PASSWORD']:
if "pbkdf2:" not in app.config["ADMIN_PASSWORD"] and app.config["ADMIN_PASSWORD"]:
# Since 2.0
warnings.warn(
"The way Ihatemoney stores your ADMIN_PASSWORD has changed. You are using an unhashed"
@ -99,20 +104,22 @@ def validate_configuration(app):
+ " endpoints. Please use the command 'ihatemoney generate_password_hash'"
+ " to generate a proper password HASH and copy the output to the value of"
+ " ADMIN_PASSWORD in your settings file.",
UserWarning
UserWarning,
)
def page_not_found(e):
return render_template('404.html', root="main"), 404
return render_template("404.html", root="main"), 404
def create_app(configuration=None, instance_path='/etc/ihatemoney',
instance_relative_config=True):
def create_app(
configuration=None, instance_path="/etc/ihatemoney", instance_relative_config=True
):
app = Flask(
__name__,
instance_path=instance_path,
instance_relative_config=instance_relative_config)
instance_relative_config=instance_relative_config,
)
# If a configuration object is passed, use it. Otherwise try to find one.
load_configuration(app, configuration)
@ -136,9 +143,9 @@ def create_app(configuration=None, instance_path='/etc/ihatemoney',
app.mail = mail
# Jinja filters
app.jinja_env.globals['static_include'] = static_include
app.jinja_env.globals['locale_from_iso'] = locale_from_iso
app.jinja_env.filters['minimal_round'] = minimal_round
app.jinja_env.globals["static_include"] = static_include
app.jinja_env.globals["locale_from_iso"] = locale_from_iso
app.jinja_env.filters["minimal_round"] = minimal_round
# Translations
babel = Babel(app)
@ -148,10 +155,10 @@ def create_app(configuration=None, instance_path='/etc/ihatemoney',
# get the lang from the session if defined, fallback on the browser "accept
# languages" header.
lang = session.get(
'lang',
request.accept_languages.best_match(app.config['SUPPORTED_LANGUAGES'])
"lang",
request.accept_languages.best_match(app.config["SUPPORTED_LANGUAGES"]),
)
setattr(g, 'lang', lang)
setattr(g, "lang", lang)
return lang
return app
@ -162,5 +169,5 @@ def main():
app.run(host="0.0.0.0", debug=True)
if __name__ == '__main__':
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,18 @@ and `add_project_id` for a quick overview)
import os
from flask import (
abort, Blueprint, current_app, flash, g, redirect, render_template, request,
session, url_for, send_file, send_from_directory
abort,
Blueprint,
current_app,
flash,
g,
redirect,
render_template,
request,
session,
url_for,
send_file,
send_from_directory,
)
from flask_mail import Message
from flask_babel import get_locale, gettext as _
@ -24,10 +34,22 @@ from functools import wraps
from ihatemoney.models import db, Project, Person, Bill
from ihatemoney.forms import (
AdminAuthenticationForm, AuthenticationForm, EditProjectForm,
InviteForm, MemberForm, PasswordReminder, ResetPasswordForm, ProjectForm, get_billform_for
AdminAuthenticationForm,
AuthenticationForm,
EditProjectForm,
InviteForm,
MemberForm,
PasswordReminder,
ResetPasswordForm,
ProjectForm,
get_billform_for,
)
from ihatemoney.utils import (
Redirect303,
list_of_dicts2json,
list_of_dicts2csv,
LoginThrottler,
)
from ihatemoney.utils import Redirect303, list_of_dicts2json, list_of_dicts2csv, LoginThrottler
main = Blueprint("main", __name__)
@ -46,17 +68,20 @@ def requires_admin(bypass=None):
Admin authentication will be bypassed when ALLOW_PUBLIC_PROJECT_CREATION is
set to True.
"""
def check_admin(f):
@wraps(f)
def admin_auth(*args, **kws):
is_admin_auth_bypassed = False
if bypass is not None and current_app.config.get(bypass[0]) == bypass[1]:
is_admin_auth_bypassed = True
is_admin = session.get('is_admin')
is_admin = session.get("is_admin")
if is_admin or is_admin_auth_bypassed:
return f(*args, **kws)
raise Redirect303(url_for('.admin', goto=request.path))
raise Redirect303(url_for(".admin", goto=request.path))
return admin_auth
return check_admin
@ -66,10 +91,10 @@ def add_project_id(endpoint, values):
This is to not carry it everywhere in the templates.
"""
if 'project_id' in values or not hasattr(g, 'project'):
if "project_id" in values or not hasattr(g, "project"):
return
if current_app.url_map.is_endpoint_expecting(endpoint, 'project_id'):
values['project_id'] = g.project.id
if current_app.url_map.is_endpoint_expecting(endpoint, "project_id"):
values["project_id"] = g.project.id
@main.url_value_preprocessor
@ -97,21 +122,19 @@ def pull_project(endpoint, values):
return
if not values:
values = {}
project_id = values.pop('project_id', None)
project_id = values.pop("project_id", None)
if project_id:
project = Project.query.get(project_id)
if not project:
raise Redirect303(url_for(".create_project",
project_id=project_id))
raise Redirect303(url_for(".create_project", project_id=project_id))
is_admin = session.get('is_admin')
is_admin = session.get("is_admin")
if session.get(project.id) or is_admin:
# add project into kwargs and call the original function
g.project = project
else:
# redirect to authentication page
raise Redirect303(
url_for(".authenticate", project_id=project_id))
raise Redirect303(url_for(".authenticate", project_id=project_id))
@main.route("/admin", methods=["GET", "POST"])
@ -121,30 +144,41 @@ def admin():
When ADMIN_PASSWORD is empty, admin authentication is deactivated.
"""
form = AdminAuthenticationForm()
goto = request.args.get('goto', url_for('.home'))
is_admin_auth_enabled = bool(current_app.config['ADMIN_PASSWORD'])
goto = request.args.get("goto", url_for(".home"))
is_admin_auth_enabled = bool(current_app.config["ADMIN_PASSWORD"])
if request.method == "POST":
client_ip = request.remote_addr
if not login_throttler.is_login_allowed(client_ip):
msg = _("Too many failed login attempts, please retry later.")
form.errors['admin_password'] = [msg]
return render_template("admin.html", form=form, admin_auth=True,
is_admin_auth_enabled=is_admin_auth_enabled)
form.errors["admin_password"] = [msg]
return render_template(
"admin.html",
form=form,
admin_auth=True,
is_admin_auth_enabled=is_admin_auth_enabled,
)
if form.validate():
# Valid password
if (check_password_hash(current_app.config['ADMIN_PASSWORD'],
form.admin_password.data)):
session['is_admin'] = True
if check_password_hash(
current_app.config["ADMIN_PASSWORD"], form.admin_password.data
):
session["is_admin"] = True
session.update()
login_throttler.reset(client_ip)
return redirect(goto)
# Invalid password
login_throttler.increment_attempts_counter(client_ip)
msg = _("This admin password is not the right one. Only %(num)d attempts left.",
num=login_throttler.get_remaining_attempts(client_ip))
form.errors['admin_password'] = [msg]
return render_template("admin.html", form=form, admin_auth=True,
is_admin_auth_enabled=is_admin_auth_enabled)
msg = _(
"This admin password is not the right one. Only %(num)d attempts left.",
num=login_throttler.get_remaining_attempts(client_ip),
)
form.errors["admin_password"] = [msg]
return render_template(
"admin.html",
form=form,
admin_auth=True,
is_admin_auth_enabled=is_admin_auth_enabled,
)
@main.route("/authenticate", methods=["GET", "POST"])
@ -152,13 +186,13 @@ def authenticate(project_id=None):
"""Authentication form"""
form = AuthenticationForm()
# Try to get project_id from token first
token = request.args.get('token')
token = request.args.get("token")
if token:
project_id = Project.verify_token(token, token_type='non_timed_token')
project_id = Project.verify_token(token, token_type="non_timed_token")
token_auth = True
else:
if not form.id.data and request.args.get('project_id'):
form.id.data = request.args['project_id']
if not form.id.data and request.args.get("project_id"):
form.id.data = request.args["project_id"]
project_id = form.id.data
token_auth = False
if project_id is None:
@ -172,16 +206,22 @@ def authenticate(project_id=None):
if not project:
# If the user try to connect to an unexisting project, we will
# propose him a link to the creation form.
return render_template("authenticate.html", form=form, create_project=project_id)
return render_template(
"authenticate.html", form=form, create_project=project_id
)
# if credentials are already in session, redirect
if session.get(project_id):
setattr(g, 'project', project)
setattr(g, "project", project)
return redirect(url_for(".list_bills"))
# else do form authentication or token authentication
is_post_auth = request.method == "POST" and form.validate()
if is_post_auth and check_password_hash(project.password, form.password.data) or token_auth:
if (
is_post_auth
and check_password_hash(project.password, form.password.data)
or token_auth
):
# maintain a list of visited projects
if "projects" not in session:
session["projects"] = []
@ -189,11 +229,11 @@ def authenticate(project_id=None):
session["projects"].insert(0, (project_id, project.name))
session[project_id] = True
session.update()
setattr(g, 'project', project)
setattr(g, "project", project)
return redirect(url_for(".list_bills"))
if is_post_auth and not check_password_hash(project.password, form.password.data):
msg = _("This private code is not the right one")
form.errors['password'] = [msg]
form.errors["password"] = [msg]
return render_template("authenticate.html", form=form)
@ -202,21 +242,27 @@ def authenticate(project_id=None):
def home():
project_form = ProjectForm()
auth_form = AuthenticationForm()
is_demo_project_activated = current_app.config['ACTIVATE_DEMO_PROJECT']
is_public_project_creation_allowed = current_app.config['ALLOW_PUBLIC_PROJECT_CREATION']
is_demo_project_activated = current_app.config["ACTIVATE_DEMO_PROJECT"]
is_public_project_creation_allowed = current_app.config[
"ALLOW_PUBLIC_PROJECT_CREATION"
]
return render_template("home.html", project_form=project_form,
is_demo_project_activated=is_demo_project_activated,
is_public_project_creation_allowed=is_public_project_creation_allowed,
auth_form=auth_form, session=session)
return render_template(
"home.html",
project_form=project_form,
is_demo_project_activated=is_demo_project_activated,
is_public_project_creation_allowed=is_public_project_creation_allowed,
auth_form=auth_form,
session=session,
)
@main.route("/create", methods=["GET", "POST"])
@requires_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True))
def create_project():
form = ProjectForm()
if request.method == "GET" and 'project_id' in request.values:
form.name.data = request.values['project_id']
if request.method == "GET" and "project_id" in request.values:
form.name.data = request.values["project_id"]
if request.method == "POST":
# At first, we don't want the user to bother with the identifier
@ -239,26 +285,34 @@ def create_project():
# send reminder email
g.project = project
message_title = _("You have just created '%(project)s' "
"to share your expenses", project=g.project.name)
message_title = _(
"You have just created '%(project)s' " "to share your expenses",
project=g.project.name,
)
message_body = render_template("reminder_mail.%s.j2" %
get_locale().language)
message_body = render_template(
"reminder_mail.%s.j2" % get_locale().language
)
msg = Message(message_title,
body=message_body,
recipients=[project.contact_email])
msg = Message(
message_title, body=message_body, recipients=[project.contact_email]
)
try:
current_app.mail.send(msg)
except SMTPRecipientsRefused:
msg_compl = 'Problem sending mail. '
msg_compl = "Problem sending mail. "
# TODO: destroy the project and cancel instead?
else:
msg_compl = ''
msg_compl = ""
# redirect the user to the next step (invite)
flash(_("%(msg_compl)sThe project identifier is %(project)s",
msg_compl=msg_compl, project=project.id))
flash(
_(
"%(msg_compl)sThe project identifier is %(project)s",
msg_compl=msg_compl,
project=project.id,
)
)
return redirect(url_for(".list_bills", project_id=project.id))
return render_template("create_project.html", form=form)
@ -273,10 +327,13 @@ def remind_password():
project = Project.query.get(form.id.data)
# send a link to reset the password
password_reminder = "password_reminder.%s.j2" % get_locale().language
current_app.mail.send(Message(
"password recovery",
body=render_template(password_reminder, project=project),
recipients=[project.contact_email]))
current_app.mail.send(
Message(
"password recovery",
body=render_template(password_reminder, project=project),
recipients=[project.contact_email],
)
)
return redirect(url_for(".password_reminder_sent"))
return render_template("password_reminder.html", form=form)
@ -287,18 +344,24 @@ def password_reminder_sent():
return render_template("password_reminder_sent.html")
@main.route('/reset-password', methods=['GET', 'POST'])
@main.route("/reset-password", methods=["GET", "POST"])
def reset_password():
form = ResetPasswordForm()
token = request.args.get('token')
token = request.args.get("token")
if not token:
return render_template('reset_password.html', form=form, error=_("No token provided"))
return render_template(
"reset_password.html", form=form, error=_("No token provided")
)
project_id = Project.verify_token(token)
if not project_id:
return render_template('reset_password.html', form=form, error=_("Invalid token"))
return render_template(
"reset_password.html", form=form, error=_("Invalid token")
)
project = Project.query.get(project_id)
if not project:
return render_template('reset_password.html', form=form, error=_("Unknown project"))
return render_template(
"reset_password.html", form=form, error=_("Unknown project")
)
if request.method == "POST" and form.validate():
project.password = generate_password_hash(form.password.data)
@ -306,7 +369,7 @@ def reset_password():
db.session.commit()
flash(_("Password successfully reset."))
return redirect(url_for(".home"))
return render_template('reset_password.html', form=form)
return render_template("reset_password.html", form=form)
@main.route("/<project_id>/edit", methods=["GET", "POST"])
@ -324,40 +387,38 @@ def edit_project():
edit_form.contact_email.data = g.project.contact_email
return render_template(
"edit_project.html",
edit_form=edit_form,
current_view="edit_project"
"edit_project.html", edit_form=edit_form, current_view="edit_project"
)
@main.route("/<project_id>/delete")
def delete_project():
g.project.remove_project()
flash(_('Project successfully deleted'))
flash(_("Project successfully deleted"))
return redirect(request.headers.get('Referer') or url_for('.home'))
return redirect(request.headers.get("Referer") or url_for(".home"))
@main.route("/<project_id>/export/<string:file>.<string:format>")
def export_project(file, format):
if file == 'transactions':
if file == "transactions":
export = g.project.get_transactions_to_settle_bill(pretty_output=True)
elif file == "bills":
export = g.project.get_pretty_bills(export_format=format)
else:
abort(404, 'No such export type')
abort(404, "No such export type")
if format == "json":
file2export = list_of_dicts2json(export)
elif format == "csv":
file2export = list_of_dicts2csv(export)
else:
abort(404, 'No such export format')
abort(404, "No such export format")
return send_file(
file2export,
attachment_filename="%s-%s.%s" % (g.project.id, file, format),
as_attachment=True
as_attachment=True,
)
@ -377,16 +438,18 @@ def demo():
Create a demo project if it doesn't exists yet (or has been deleted)
If the demo project is deactivated, one is redirected to the create project form
"""
is_demo_project_activated = current_app.config['ACTIVATE_DEMO_PROJECT']
is_demo_project_activated = current_app.config["ACTIVATE_DEMO_PROJECT"]
project = Project.query.get("demo")
if not project and not is_demo_project_activated:
raise Redirect303(url_for(".create_project",
project_id='demo'))
raise Redirect303(url_for(".create_project", project_id="demo"))
if not project and is_demo_project_activated:
project = Project(id="demo", name=u"demonstration",
password=generate_password_hash("demo"),
contact_email="demo@notmyidea.org")
project = Project(
id="demo",
name="demonstration",
password=generate_password_hash("demo"),
contact_email="demo@notmyidea.org",
)
db.session.add(project)
db.session.commit()
session[project.id] = True
@ -403,15 +466,19 @@ def invite():
if form.validate():
# send the email
message_body = render_template("invitation_mail.%s.j2" %
get_locale().language)
message_body = render_template(
"invitation_mail.%s.j2" % get_locale().language
)
message_title = _("You have been invited to share your "
"expenses for %(project)s", project=g.project.name)
msg = Message(message_title,
body=message_body,
recipients=[email.strip()
for email in form.emails.data.split(",")])
message_title = _(
"You have been invited to share your " "expenses for %(project)s",
project=g.project.name,
)
msg = Message(
message_title,
body=message_body,
recipients=[email.strip() for email in form.emails.data.split(",")],
)
current_app.mail.send(msg)
flash(_("Your invitations have been sent"))
return redirect(url_for(".list_bills"))
@ -423,17 +490,19 @@ def invite():
def list_bills():
bill_form = get_billform_for(g.project)
# set the last selected payer as default choice if exists
if 'last_selected_payer' in session:
bill_form.payer.data = session['last_selected_payer']
if "last_selected_payer" in session:
bill_form.payer.data = session["last_selected_payer"]
# Preload the "owers" relationship for all bills
bills = g.project.get_bills().options(orm.subqueryload(Bill.owers))
return render_template("list_bills.html",
bills=bills, member_form=MemberForm(g.project),
bill_form=bill_form,
add_bill=request.values.get('add_bill', False),
current_view="list_bills",
)
return render_template(
"list_bills.html",
bills=bills,
member_form=MemberForm(g.project),
bill_form=bill_form,
add_bill=request.values.get("add_bill", False),
current_view="list_bills",
)
@main.route("/<project_id>/members/add", methods=["GET", "POST"])
@ -452,8 +521,11 @@ def add_member():
@main.route("/<project_id>/members/<member_id>/reactivate", methods=["POST"])
def reactivate(member_id):
person = Person.query.filter(Person.id == member_id)\
.filter(Project.id == g.project.id).all()
person = (
Person.query.filter(Person.id == member_id)
.filter(Project.id == g.project.id)
.all()
)
if person:
person[0].activated = True
db.session.commit()
@ -466,23 +538,27 @@ def remove_member(member_id):
member = g.project.remove_member(member_id)
if member:
if not member.activated:
flash(_("User '%(name)s' has been deactivated. It will still "
flash(
_(
"User '%(name)s' has been deactivated. It will still "
"appear in the users list until its balance "
"becomes zero.", name=member.name))
"becomes zero.",
name=member.name,
)
)
else:
flash(_("User '%(name)s' has been removed", name=member.name))
return redirect(url_for(".list_bills"))
@main.route("/<project_id>/members/<member_id>/edit",
methods=["POST", "GET"])
@main.route("/<project_id>/members/<member_id>/edit", methods=["POST", "GET"])
def edit_member(member_id):
member = Person.query.get(member_id, g.project)
if not member:
raise NotFound()
form = MemberForm(g.project, edit=True)
if request.method == 'POST' and form.validate():
if request.method == "POST" and form.validate():
form.save(g.project, member)
db.session.commit()
flash(_("User '%(name)s' has been edited", name=member.name))
@ -495,10 +571,10 @@ def edit_member(member_id):
@main.route("/<project_id>/add", methods=["GET", "POST"])
def add_bill():
form = get_billform_for(g.project)
if request.method == 'POST':
if request.method == "POST":
if form.validate():
# save last selected payer in session
session['last_selected_payer'] = form.payer.data
session["last_selected_payer"] = form.payer.data
session.update()
bill = Bill()
@ -509,9 +585,9 @@ def add_bill():
args = {}
if form.submit2.data:
args['add_bill'] = True
args["add_bill"] = True
return redirect(url_for('.list_bills', **args))
return redirect(url_for(".list_bills", **args))
return render_template("add_bill.html", form=form)
@ -521,13 +597,13 @@ def delete_bill(bill_id):
# fixme: everyone is able to delete a bill
bill = Bill.query.get(g.project, bill_id)
if not bill:
return redirect(url_for('.list_bills'))
return redirect(url_for(".list_bills"))
db.session.delete(bill)
db.session.commit()
flash(_("The bill has been deleted"))
return redirect(url_for('.list_bills'))
return redirect(url_for(".list_bills"))
@main.route("/<project_id>/edit/<int:bill_id>", methods=["GET", "POST"])
@ -539,12 +615,12 @@ def edit_bill(bill_id):
form = get_billform_for(g.project, set_default=False)
if request.method == 'POST' and form.validate():
if request.method == "POST" and form.validate():
form.save(bill, g.project)
db.session.commit()
flash(_("The bill has been modified"))
return redirect(url_for('.list_bills'))
return redirect(url_for(".list_bills"))
if not form.errors:
form.fill(bill)
@ -554,21 +630,17 @@ def edit_bill(bill_id):
@main.route("/lang/<lang>")
def change_lang(lang):
session['lang'] = lang
session["lang"] = lang
session.update()
return redirect(request.headers.get('Referer') or url_for('.home'))
return redirect(request.headers.get("Referer") or url_for(".home"))
@main.route("/<project_id>/settle_bills")
def settle_bill():
"""Compute the sum each one have to pay to each other and display it"""
bills = g.project.get_transactions_to_settle_bill()
return render_template(
"settle_bills.html",
bills=bills,
current_view='settle_bill',
)
return render_template("settle_bills.html", bills=bills, current_view="settle_bill")
@main.route("/<project_id>/statistics")
@ -577,22 +649,25 @@ def statistics():
return render_template(
"statistics.html",
members_stats=g.project.members_stats,
current_view='statistics',
current_view="statistics",
)
@main.route("/dashboard")
@requires_admin()
def dashboard():
is_admin_dashboard_activated = current_app.config['ACTIVATE_ADMIN_DASHBOARD']
is_admin_dashboard_activated = current_app.config["ACTIVATE_ADMIN_DASHBOARD"]
return render_template(
"dashboard.html",
projects=Project.query.all(),
is_admin_dashboard_activated=is_admin_dashboard_activated
is_admin_dashboard_activated=is_admin_dashboard_activated,
)
@main.route('/favicon.ico')
@main.route("/favicon.ico")
def favicon():
return send_from_directory(os.path.join(main.root_path, 'static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
return send_from_directory(
os.path.join(main.root_path, "static"),
"favicon.ico",
mimetype="image/vnd.microsoft.icon",
)