Commit 1ec3ef9a authored by Luca Cristaldi's avatar Luca Cristaldi

Apply pokoli's sugegstion

parent c0a7f025
Copyright (C) 2018 Luca Cristaldi
Copyright (C) 2019 Luca Cristaldi
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
......
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.pool import Pool
from . import account
......
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.pool import PoolMeta
......@@ -12,4 +12,4 @@ class Move(metaclass=PoolMeta):
@classmethod
def _get_origin(cls):
return super(Move, cls)._get_origin() + ['association.membership.fee']
return super()._get_origin() + ['association.membership.fee']
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.model import ModelView, ModelSQL, ModelSingleton, fields
from trytond.pyson import Eval
......@@ -8,8 +8,8 @@ from trytond.modules.company.model import (CompanyMultiValueMixin,
__all__ = ['Configuration', 'MemberSequence']
class Configuration(ModelSingleton, ModelSQL, ModelView,
CompanyMultiValueMixin):
class Configuration(
ModelSingleton, ModelSQL, ModelView, CompanyMultiValueMixin):
"""Association Configuration"""
__name__ = 'association.configuration'
member_sequence = fields.MultiValue(
......
<?xml version="1.0" ?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
......
===========
Association
===========
------
Member
------
A member is defined by:
* code: a sequential code, you can specify the format in the Configuration tab
* Party: The party associated with this member
* join_date: The date were the member joins the association
* leave_date: The date were the member leaves the association
* memberships: A list of Mebership Line
* memberships: A list of Membership Line
If you want easilly check if the member paied all his fees, you can use the "Fees" relate utility.
If you want easily check if the member paid all his fees,
you can use the "Fees" relate utility.
---------------
Membership Line
---------------
The membership line is used to specify the starting and ending date of the membership.
This so you can selecet a starting/ending date different from the member one.
The membership line is used to specify the starting and ending date of
the membership.
This so you can select a starting/ending date different from the member one.
If you leave the fields empty, it will use the join/leave date from the member.
A membership line is defined by:
* membership: the membership the member enrolled
* start_date: the strating date, if not specified use the join date from the member will be used
* end_date: the ending date, if not specified the leave date from the member will be used
This is used by the "Create Fees" Wizard to filter wich period the user should be billed
* membership: the membership the member enrolled
* start_date: the starting date, if not specified the join date from
the member will be used
* end_date: the ending date, if not specified the leave date from
the member will be used
This is used by the "Create Fees" Wizard to filter witch period
the user should be billed
----------
Membership
----------
A membership is a collection of Periods.
The list must contain Periods with no overlapping date.
A Membership is defined by:
* name: The name of the Membership
* journal: The Journal that will be used for posting accounting move
* account_revenue: The account revenue that will be used for posting accounting move
* periods: A list of Periods
*******************
* name: The name of the Membership
* journal: The Journal that will be used for posting accounting move
* account_revenue: The account revenue that will be used
for posting accounting move
* periods: A list of Periods
Print Member's Book
*******************
This wizard will print a Report of all the member that are not in the draft state.
===================
This wizard will print a Report of all the member
that are not in the draft state.
------
Period
------
A periods define a date range and what the member should pay for the period
A period is defined by:
* start_date: the starting date of the period
* end_date: the ending date of the period
* fee: the amount that the member should pay for this period
* start_date: the starting date of the period
* end_date: the ending date of the period
* fee: the amount that the member should pay for this period
---
Fee
---
A fee is associated to a Member and a Period.
Tryton will check if a Member have Fees with the same period,and will throw an error if you try to do so.
A feee is defined as:
* member: The Member associated with this fee
* period: The Period associated with this fee
* move: The move associated with this fee
Tryton will check if a Member have Fees with the same period,
and will throw an error if you try to do so.
A fee is defined as:
* member: The Member associated with this fee
* period: The Period associated with this fee
* move: The move associated with this fee
The module will check if the move is reconciled and posted, if so the paid field will be checked.
The fee will be considered as paid when the move is reconciled and posted.
You can post the move via the Post button/action, or manually.
******************
Create Fees Wizard
******************
==================
The wizard will generate fee record based on the date.
This wizard is used by the cronjob that will trigger (by default) once every day.
This wizard is used by the cronjob that will trigger (by default)
once every day.
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.model.exceptions import ValidationError
......
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.pool import PoolMeta
......
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from trytond.model import Workflow, ModelView, ModelSQL, fields, Unique
from trytond.pyson import Eval, If, Not, Or
from trytond.pyson import Eval, If
from trytond.tools import lstrip_wildcard
from trytond.transaction import Transaction
from trytond.pool import Pool
from trytond.i18n import gettext
......@@ -14,82 +15,89 @@ __all__ = [
'Member', 'MembersBookWizard', 'PrintMembersBookStart', 'MembersBookReport'
]
STATES = [
('draft', 'Draft'),
('running', 'Running'),
('stopped', 'Stopped'),
]
_DEPENDS = ['state']
_STATE = {'readonly': Eval('state') != 'draft'}
class Member(Workflow, ModelSQL, ModelView):
'Member'
__name__ = "association.member"
state = fields.Selection(STATES, 'State', readonly=True)
code = fields.Char('Code', select=True,
states={
'readonly': Eval('code_readonly', True),
'required': Eval('state') != 'draft'},
depends=['code_readonly', 'state'],
help="The internal identifier for the associate.")
code_readonly = fields.Function(fields.Boolean('Code Readonly'),
'get_code_readonly')
company = fields.Many2One('company.company', 'Company', states=_STATE,
depends=_DEPENDS,
required=True,
help="The association this member belongs to.")
party = fields.Many2One('party.party', 'Party', states=_STATE,
depends=_DEPENDS,
required=True,
help="The party that represents the member.")
join_date = fields.Date('Join Date',
states={
'readonly':
Or(
Not(Eval('state').in_(['draft'])),
Eval('join_date', False)
),
'required': Eval('state') != 'draft'},
domain=[
If(
(Eval('join_date')) & (Eval('leave_date')),
('join_date', '<=', Eval('leave_date')),
(),
)],
depends=['leave_date', 'state'],
help="The date the member joined the association.")
leave_date = fields.Date('Leave Date',
domain=[
If(
(Eval('join_date')) & (Eval('leave_date')),
('leave_date', '>=', Eval('join_date')),
(),
)],
depends=['join_date', 'state'],
states={
'invisible':
Or(
Not(Eval('state').in_(['running', 'stopped'])),
Eval('leave_date', False)
),
'readonly': Eval('state') != 'running'
},
help="The date the member left the association.")
memberships = fields.One2Many('association.membership', 'member',
"Memberships",
domain=[
('company', '=', Eval('context', {}).get('company', -1))
],
depends=['state', 'company'],
states={'readonly': Eval('state') != 'draft'})
_states = {'readonly': Eval('state') != 'draft'}
_depends = ['state']
state = fields.Selection([
('draft', 'Draft'),
('running', 'Running'),
('stopped', 'Stopped'),
],
"State",
readonly=True)
code = fields.Char("Code",
select=True,
states={
'readonly': Eval('code_readonly', True),
'required': Eval('state') != 'draft'
},
depends=_depends + ['code_readonly'],
help="The internal identifier for the associate.")
code_readonly = fields.Function(fields.Boolean("Code Readonly"),
'get_code_readonly')
company = fields.Many2One('company.company',
"Company",
states={'readonly': Eval('state') != 'draft'},
depends=_depends,
required=True,
help="The association this member belongs to.")
party = fields.Many2One('party.party',
"Party",
states={'readonly': Eval('state') != 'draft'},
depends=_depends,
required=True,
help="The party that represents the member.")
join_date = fields.Date("Join Date",
states={
'readonly':
(Eval('state') != 'draft') | Eval('join_date'),
'required':
Eval('state') != 'draft'
},
domain=[
If(
(Eval('join_date')) & (Eval('leave_date')),
('join_date', '<=', Eval('leave_date')),
(),
)
],
depends=_depends + ['leave_date'],
help="The date the member joined the association.")
leave_date = fields.Date("Leave Date",
domain=[
If(
(Eval('join_date')) &
(Eval('leave_date')),
('leave_date', '>=', Eval('join_date')),
(),
)
],
depends=_depends + ['join_date'],
states={
'invisible': Eval('state') == 'draft',
'required': Eval('state') == 'stopped',
'readonly': Eval('state') != 'running'
},
help="The date the member left the association.")
memberships = fields.One2Many('association.membership',
'member',
"Memberships",
domain=[
('company', '=', Eval('company', -1))
],
depends=_depends + ['company'],
states=_states)
del _states, _depends
@classmethod
def __setup__(cls):
super(Member, cls).__setup__()
super().__setup__()
t = cls.__table__()
cls._sql_constraints = [
('code_uniq', Unique(t, t.code), 'Member code must be unique.'),
......@@ -130,7 +138,18 @@ class Member(Workflow, ModelSQL, ModelView):
@classmethod
def search_rec_name(cls, name, clause):
return [('party', ) + tuple(clause[1:])]
if clause[1].startswith('!') or clause[1].startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
code_value = clause[2]
if clause[1].endswith('like'):
code_value = lstrip_wildcard(clause[2])
return [
bool_op,
('code', clause[1], code_value) + tuple(clause[3:]),
('party', ) + tuple(clause[1:]),
]
@classmethod
def copy(cls, members, default=None):
......@@ -139,7 +158,8 @@ class Member(Workflow, ModelSQL, ModelView):
else:
default = default.copy()
default.setdefault('code', None)
return super(Member, cls).copy(members, default=default)
default.setdefault('leave_date', None)
return super().copy(members, default=default)
@classmethod
def default_state(cls):
......@@ -181,7 +201,6 @@ class Member(Workflow, ModelSQL, ModelView):
raise MemberUnpaidFeeError(
gettext('association.msg_unpaid_fee',
member=", ".join([x.rec_name for x in members])))
cls.save(members)
class MembersBookReport(Report):
......@@ -194,7 +213,7 @@ class MembersBookReport(Report):
Member = pool.get('association.member')
Company = pool.get('company.company')
clause = [('state', '!=', 'draft'), ('company', '=', data['company'])]
context = super(MembersBookReport, cls).get_context(records, data)
context = super().get_context(records, data)
members = Member.search(clause)
context["members"] = members
context["company"] = Company(data['company'])
......
<?xml version="1.0" ?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data depends="">
......
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from decimal import Decimal
from trytond.config import config
......@@ -29,14 +29,14 @@ _FEE_STATES = {
}
_MEMBER_STATES = [
('draft', 'Draft'),
('running', 'Running'),
('stopped', 'Stopped'),
('draft', "Draft"),
('running', "Running"),
('stopped', "Stopped"),
]
_MOVE_STATE_SELECTION = [
('draft', 'Draft'),
('posted', 'Posted'),
('draft', "Draft"),
('posted', "Posted"),
]
......@@ -46,13 +46,13 @@ class MembershipType(ModelSQL, ModelView):
name = fields.Char('Name', size=None, required=True)
periods = fields.One2Many('association.membership.period', 'membership',
'Periods',
"Periods",
help="The periods that form the basis of the membership type.")
journal = fields.Many2One('account.journal', 'Journal', required=True,
journal = fields.Many2One('account.journal', "Journal", required=True,
domain=[('type', '=', 'revenue')],
ondelete='RESTRICT',
help="The accounting journal that is used for the fee.")
account_revenue = fields.Many2One('account.account', 'Account Revenue',
account_revenue = fields.Many2One('account.account', "Account Revenue",
domain=[
('type.revenue', '=', True),
('company', '=', Eval('context', {}).get('company', -1)),
......@@ -91,12 +91,12 @@ class Membership(ModelSQL, ModelView):
depends=['member_state', 'company'],
states=_LINE_STATES,
ondelete='RESTRICT')
company = fields.Many2One('company.company', 'Company', required=True,
company = fields.Many2One('company.company', "Company", required=True,
domain=[
('id', If(Eval('context', {}).contains('company'), '=', '!='),
Eval('context', {}).get('company', -1)),
], select=True, states=_LINE_STATES, depends=['member_state'])
start_date = fields.Date('Start Date',
start_date = fields.Date("Start Date",
domain=[
If(
And((Eval('start_date')),
......@@ -107,7 +107,7 @@ class Membership(ModelSQL, ModelView):
depends=['end_date', 'member_state'],
states=_LINE_STATES,
help="The date when the membership starts.")
end_date = fields.Date('End Date',
end_date = fields.Date("End Date",
domain=[
If(
And(Eval('start_date'), Eval('end_date')),
......@@ -148,7 +148,7 @@ class Period(ModelSQL, ModelView):
"""Membership period"""
__name__ = 'association.membership.period'
membership = fields.Many2One('association.membership.type', 'Membership',
membership = fields.Many2One('association.membership.type', "Membership",
required=True,
select=True,
domain=[
......@@ -156,21 +156,21 @@ class Period(ModelSQL, ModelView):
],
depends=['company'],
ondelete='RESTRICT')
company = fields.Many2One('company.company', 'Company', required=True,
company = fields.Many2One('company.company', "Company", required=True,
select=True,
domain=[
('id', If(Eval('context', {}).contains('company'), '=', '!='),
Eval('context', {}).get('company', -1)),
],
ondelete='RESTRICT')
name = fields.Char('Name', size=None, required=True)
start_date = fields.Date('Starting Date', required=True,
name = fields.Char("Name", size=None, required=True)
start_date = fields.Date("Starting Date", required=True,
domain=[
('start_date', '<=',
Eval('end_date', None))
],
depends=['end_date'])
end_date = fields.Date('Ending Date', required=True,
end_date = fields.Date("Ending Date", required=True,
domain=[('end_date', '>=', Eval('start_date', None))],
depends=['start_date'])
amount = fields.Numeric("Amount", fee_digit, required=True,
......@@ -182,7 +182,7 @@ class Period(ModelSQL, ModelView):
@classmethod
def validate(cls, periods):
super(Period, cls).validate(periods)
super().validate(periods)
for period in periods:
period.check_dates()
period.check_fee()
......@@ -234,7 +234,7 @@ class Fee(ModelSQL, ModelView):
move = fields.Many2One('account.move', "Move", ondelete='RESTRICT',
states=_FEE_STATES,
help="The accounting move associated to this fee.")
company = fields.Many2One('company.company', 'Company', required=True,
company = fields.Many2One('company.company', "Company", required=True,
select=True,
domain=[
('id', If(Eval('context', {}).contains('company'), '=', '!='),
......@@ -245,7 +245,7 @@ class Fee(ModelSQL, ModelView):
move_state = fields.Function(fields.Selection(_MOVE_STATE_SELECTION,
"Move State"), 'on_change_with_move_state')
paid = fields.Function(fields.Boolean('paid'), 'is_paid')
reconciled = fields.Function(fields.Date('Reconciled'), 'get_reconciled')
reconciled = fields.Function(fields.Date("Reconciled"), 'get_reconciled')
amount = fields.Function(fields.Numeric("Amount", fee_digit),
'on_change_with_amount')
......@@ -256,7 +256,7 @@ class Fee(ModelSQL, ModelView):
@classmethod
def __setup__(cls):
super(Fee, cls).__setup__()
super().__setup__()
table = cls.__table__()
cls._sql_constraints = [
......@@ -284,7 +284,7 @@ class Fee(ModelSQL, ModelView):
if fee.move or fee.paid:
raise AccessError(
gettext('association.msg_fee_delete', fee=fee.rec_name))
super(Fee, cls).delete(fees)
super().delete(fees)
@classmethod
def default_company(cls):
......
<?xml version="1.0" ?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data depends="">
......
<?xml version="1.0" ?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data group="1">
<record model="ir.message" id="msg_date_non_in_period">
<field name="text">The date is outside the "%(period)s"</field>
<field name="text">The date is outside the "%(period)s".</field>
</record>
<record model="ir.message" id="msg_overlapping_period">
<field name="text">The period "%(first)s" overlaps with the period "%(second)s"</field>
<field name="text">The period "%(first)s" overlaps with the period "%(second)s".</field>
</record>
<record model="ir.message" id="msg_fee_delete">
<field name="text">You cannot delete the fee "%(fee)s"</field>
<field name="text">You cannot delete the fee "%(fee)s".</field>
</record>
<record model="ir.message" id="msg_unpaid_fee">
<field name="text">You cannot expel the member "%(member)s" because there are still some unpaid fees</field>
<field name="text">You cannot expel the member "%(member)s" because there are still some unpaid fees.</field>
</record>
<record model="ir.message" id="msg_period_fee_invalid">
<field name="text">The period "%(period)s" fee amount should have a value >= zero</field>
<field name="text">The period "%(period)s" fee amount should have a value greather than zero.</field>
</record>
<record model="ir.message" id="msg_party_receivable_missing">
<field name="text">The party "%(party)s" doesn't have an account receivable defined</field>
<field name="text">The party "%(party)s" doesn't have an account receivable defined.</field>
</record>
</data>
</tryton>
\ No newline at end of file
#!/usr/bin/env python3
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import io
import os
......
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
try:
......
......@@ -77,11 +77,8 @@ Create member::
>>> Member = Model.get('association.member')
>>> member = Member()
>>> member.party = party
>>> member.company = company
>>> membership_line = member.memberships.new(membership=membership)
>>> member.save()
>>> membership_line.start_date = start_date
>>> membership_line.end_date = start_date + timedelta
Test membership inverse date::
......@@ -89,10 +86,10 @@ Test membership inverse date::
... membership=membership,
... start_date=start_date +timedelta,
... end_date=start_date)
>>> member.save() #doctest: +ELLIPSIS
>>> member.save() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
trytond.model.modelstorage.DomainValidationError: ('UserError', (...))
DomainValidationError: ...
>>> member.memberships.remove(membership_line)
>>> member.save()
......@@ -107,10 +104,10 @@ Test memebrship fee::
>>> Fee = Model.get('association.membership.fee')
>>> period, = Period.find(["name","=","period 1"])
>>> fee = Fee(member=member,period=period)
>>> fee.save() #doctest: +ELLIPSIS
>>> fee.save() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
trytond.model.modelstorage.DomainValidationError: ('UserError', (...))
DomainValidationError: ...
Make member active::
......@@ -130,12 +127,21 @@ Test memebrship fee move::
>>> fee.move #doctest: +ELLIPSIS
proteus.Model.get('account.move')(...)
Test member leave_date < join_date::
>>> member.leave_date = start_date - timedelta
>>> member.save() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
DomainValidationError: ...
Test member expulsion::
>>> member.click('stop') #doctest: +ELLIPSIS
>>> member.leave_date = start_date + timedelta
>>> member.click('stop') #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
trytond.modules.association.exceptions.MemberUnpaidFeeError: (...)
MemberUnpaidFeeError: ...
>>> move_wiz = Wizard('account.move.cancel',[fee.move])
>>> move_wiz.form.description = 'Cancel'
>>> move_wiz.execute('cancel')
......
......@@ -79,17 +79,17 @@ Create membership::
>>> datedeltas = [(start_date + timedelta*(n-1) + datetime.timedelta(days=1),start_date + timedelta*n,f"period {n}") for n in range(1,fiscalMonth+1)]
>>> periods = [ membership.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal(42)) for period in datedeltas ]
>>> membership.save()
>>> overlapping =membership.periods.new(start_date=start_date,end_date=start_date+timedelta,name="overlapping",amount=Decimal(42)).save() #doctest: +ELLIPSIS
>>> overlapping =membership.periods.new(start_date=start_date,end_date=start_date+timedelta,name="overlapping",amount=Decimal(42)).save() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
trytond.modules.association.exceptions.PeriodDateOverlapError: ('UserError', ...)
PeriodDateOverlapError: ...
>>> new_date = datetime.date(2020,1,1)
>>> inverted = membership.periods.new(start_date=new_date + timedelta,end_date=start_date,name="inverted",amount=Decimal(42)).save() #doctest: +ELLIPSIS
>>> inverted = membership.periods.new(start_date=new_date + timedelta,end_date=start_date,name="inverted",amount=Decimal(42)).save() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
trytond.model.modelstorage.DomainValidationError: ('UserError', ...)
>>> negative = membership.periods.new(start_date=new_date,end_date=new_date + timedelta,name="negative 1",amount=Decimal(-10)).save() #doctest: +ELLIPSIS
DomainValidationError: ...
>>> negative = membership.periods.new(start_date=new_date,end_date=new_date + timedelta,name="negative 1",amount=Decimal(-10)).save() #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
trytond.modules.association.exceptions.AmountLessThanZeroError: ('UserError', ...)
AmountLessThanZeroError: ...
# This file is part of Tryton. The COPYRIGHT file at the top level of
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import unittest
......
# This file is part of Tryton. The COPYRIGHT file at the top level of