mirror of
https://gitlab.com/flectra-community/account-closing.git
synced 2024-11-23 14:12:04 +00:00
681 lines
24 KiB
Python
681 lines
24 KiB
Python
# Copyright 2016 Tecnativa - Antonio Espinosa
|
|
# Copyright 2017 Tecnativa - Pedro M. Baeza
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
|
|
import logging
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from flectra import _, api, exceptions, fields, models
|
|
from flectra.exceptions import ValidationError
|
|
from flectra.tools import float_is_zero
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AccountFiscalyearClosing(models.Model):
|
|
_inherit = "account.fiscalyear.closing.abstract"
|
|
_name = "account.fiscalyear.closing"
|
|
_description = "Fiscal year closing"
|
|
|
|
def _default_year(self):
|
|
company = self._default_company_id()
|
|
lock_date = company.fiscalyear_lock_date or fields.Date.today()
|
|
fiscalyear = lock_date.year
|
|
if (
|
|
lock_date.month < int(company.fiscalyear_last_month)
|
|
and lock_date.day < company.fiscalyear_last_day
|
|
):
|
|
fiscalyear = fiscalyear - 1
|
|
return fiscalyear
|
|
|
|
def _default_company_id(self):
|
|
return self.env.company
|
|
|
|
name = fields.Char(
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
check_draft_moves = fields.Boolean(
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
year = fields.Integer(
|
|
help="Introduce here the year to close. If the fiscal year is between "
|
|
"several natural years, you have to put here the last one.",
|
|
default=lambda self: self._default_year(),
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
company_id = fields.Many2one(
|
|
default=lambda self: self._default_company_id(),
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
chart_template_id = fields.Many2one(
|
|
comodel_name="account.chart.template",
|
|
string="Chart template",
|
|
related="company_id.chart_template_id",
|
|
readonly=True,
|
|
)
|
|
state = fields.Selection(
|
|
selection=[
|
|
("draft", "Draft"),
|
|
("calculated", "Processed"),
|
|
("posted", "Posted"),
|
|
("cancelled", "Cancelled"),
|
|
],
|
|
string="State",
|
|
readonly=True,
|
|
default="draft",
|
|
)
|
|
calculation_date = fields.Datetime(
|
|
string="Calculation date",
|
|
readonly=True,
|
|
)
|
|
date_start = fields.Date(
|
|
string="From date",
|
|
required=True,
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
date_end = fields.Date(
|
|
string="To date",
|
|
required=True,
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
date_opening = fields.Date(
|
|
string="Opening date",
|
|
required=True,
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
closing_template_id = fields.Many2one(
|
|
comodel_name="account.fiscalyear.closing.template",
|
|
string="Closing template",
|
|
domain="[('chart_template_ids', '=', chart_template_id)]",
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
move_config_ids = fields.One2many(
|
|
comodel_name="account.fiscalyear.closing.config",
|
|
inverse_name="fyc_id",
|
|
string="Moves configuration",
|
|
readonly=True,
|
|
states={"draft": [("readonly", False)]},
|
|
)
|
|
move_ids = fields.One2many(
|
|
comodel_name="account.move",
|
|
inverse_name="fyc_id",
|
|
string="Moves",
|
|
readonly=True,
|
|
)
|
|
|
|
_sql_constraints = [
|
|
(
|
|
"year_company_uniq",
|
|
"unique(year, company_id)",
|
|
_(
|
|
"There should be only one fiscal year closing for that year and "
|
|
"company!"
|
|
),
|
|
),
|
|
]
|
|
|
|
def _prepare_mapping(self, tmpl_mapping):
|
|
self.ensure_one()
|
|
dest_account = False
|
|
# Find the destination account
|
|
name = tmpl_mapping.name
|
|
if tmpl_mapping.dest_account:
|
|
dest_account = self.env["account.account"].search(
|
|
[
|
|
("company_id", "=", self.company_id.id),
|
|
("code", "=ilike", tmpl_mapping.dest_account),
|
|
],
|
|
limit=1,
|
|
)
|
|
# Use an error name if no destination account found
|
|
if not dest_account:
|
|
name = _("No destination account '%s' found.") % (
|
|
tmpl_mapping.dest_account,
|
|
)
|
|
return {
|
|
"name": name,
|
|
"src_accounts": tmpl_mapping.src_accounts,
|
|
"dest_account_id": dest_account,
|
|
}
|
|
|
|
@api.model
|
|
def _prepare_type(self, tmpl_type):
|
|
return {
|
|
"account_type_id": tmpl_type.account_type_id,
|
|
"closing_type": tmpl_type.closing_type,
|
|
}
|
|
|
|
def _get_default_journal(self, company):
|
|
"""To be inherited if we want to change the default journal."""
|
|
journal_obj = self.env["account.journal"]
|
|
domain = [("company_id", "=", company.id)]
|
|
journal = journal_obj.search(
|
|
domain + [("code", "=", "MISC")],
|
|
limit=1,
|
|
)
|
|
if not journal:
|
|
journal = journal_obj.search(
|
|
domain + [("type", "=", "general")],
|
|
limit=1,
|
|
)
|
|
return journal
|
|
|
|
def _prepare_config(self, tmpl_config):
|
|
self.ensure_one()
|
|
mappings = self.env["account.fiscalyear.closing.mapping"]
|
|
for m in tmpl_config.mapping_ids:
|
|
mappings += mappings.new(self._prepare_mapping(m))
|
|
types = self.env["account.fiscalyear.closing.type"]
|
|
for t in tmpl_config.closing_type_ids:
|
|
types += types.new(self._prepare_type(t))
|
|
if tmpl_config.move_date == "last_ending":
|
|
date = self.date_end
|
|
else:
|
|
date = self.date_opening
|
|
return {
|
|
"enabled": True,
|
|
"name": tmpl_config.name,
|
|
"sequence": tmpl_config.sequence,
|
|
"code": tmpl_config.code,
|
|
"inverse": tmpl_config.inverse,
|
|
"move_type": tmpl_config.move_type,
|
|
"date": date,
|
|
"journal_id": (
|
|
tmpl_config.journal_id or self._get_default_journal(self.company_id).id
|
|
),
|
|
"mapping_ids": mappings,
|
|
"closing_type_ids": types,
|
|
"closing_type_default": tmpl_config.closing_type_default,
|
|
}
|
|
|
|
@api.onchange("closing_template_id")
|
|
def onchange_template_id(self):
|
|
self.move_config_ids = False
|
|
if not self.closing_template_id:
|
|
return
|
|
config_obj = self.env["account.fiscalyear.closing.config"]
|
|
tmpl = self.closing_template_id.with_context(force_company=self.company_id.id)
|
|
self.check_draft_moves = tmpl.check_draft_moves
|
|
for tmpl_config in tmpl.move_config_ids:
|
|
self.move_config_ids += config_obj.new(self._prepare_config(tmpl_config))
|
|
|
|
@api.onchange("year")
|
|
def _onchange_year(self):
|
|
self.date_end = "{}-{}-{}".format(
|
|
self.year,
|
|
str(self.company_id.fiscalyear_last_month).zfill(2) or "12",
|
|
str(self.company_id.fiscalyear_last_day).zfill(2) or "31",
|
|
)
|
|
date_end = fields.Date.from_string(self.date_end)
|
|
self.date_start = fields.Date.to_string(
|
|
date_end - relativedelta(years=1) + relativedelta(days=1)
|
|
)
|
|
self.date_opening = fields.Date.to_string(date_end + relativedelta(days=1))
|
|
if self.date_start != self.date_end:
|
|
self.name = "{}-{}".format(self.date_start, self.date_end)
|
|
else:
|
|
self.name = str(self.date_end)
|
|
|
|
def draft_moves_check(self):
|
|
for closing in self:
|
|
draft_moves = self.env["account.move"].search(
|
|
[
|
|
("company_id", "=", closing.company_id.id),
|
|
("state", "=", "draft"),
|
|
("date", ">=", closing.date_start),
|
|
("date", "<=", closing.date_end),
|
|
]
|
|
)
|
|
if draft_moves:
|
|
msg = _("One or more draft moves found: \n")
|
|
for move in draft_moves:
|
|
msg += "ID: {}, Date: {}, Number: {}, Ref: {}\n".format(
|
|
move.id,
|
|
move.date,
|
|
move.name,
|
|
move.ref,
|
|
)
|
|
raise ValidationError(msg)
|
|
return True
|
|
|
|
def _show_unbalanced_move_wizard(self, data):
|
|
"""When a move is not balanced, a wizard is presented for checking the
|
|
possible problem. This method fills the records and return the
|
|
corresponding action for showing that wizard.
|
|
|
|
:param data: Dictionary with the values of the unbalanced move.
|
|
:return: Dictionary with the action for showing the wizard.
|
|
"""
|
|
del data["closing_type"]
|
|
del data["fyc_id"]
|
|
wizard = self.env["account.fiscalyear.closing.unbalanced.move"].create(data)
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"name": _("Unbalanced journal entry found"),
|
|
"view_type": "form",
|
|
"view_mode": "form",
|
|
"res_model": "account.fiscalyear.closing.unbalanced.move",
|
|
"res_id": wizard.id,
|
|
"target": "new",
|
|
}
|
|
|
|
def calculate(self):
|
|
for closing in self:
|
|
# Perform checks, raise exception if check fails
|
|
if closing.check_draft_moves:
|
|
closing.draft_moves_check()
|
|
for config in closing.move_config_ids.filtered("enabled"):
|
|
move, data = config.moves_create()
|
|
if not move and data:
|
|
# The move can't be created
|
|
return self._show_unbalanced_move_wizard(data)
|
|
return True
|
|
|
|
def _moves_remove(self):
|
|
for closing in self:
|
|
closing.mapped("move_ids.line_ids").filtered(
|
|
"reconciled"
|
|
).remove_move_reconcile()
|
|
closing.move_ids.button_cancel()
|
|
closing.move_ids.unlink()
|
|
return True
|
|
|
|
def button_calculate(self):
|
|
res = self.calculate()
|
|
if res is True:
|
|
# Change state only on successful creation
|
|
self.write(
|
|
{
|
|
"state": "calculated",
|
|
"calculation_date": fields.Datetime.now(),
|
|
}
|
|
)
|
|
else:
|
|
# Remove intermediate moves already created
|
|
self._moves_remove()
|
|
return res
|
|
|
|
def button_recalculate(self):
|
|
self._moves_remove()
|
|
return self.button_calculate()
|
|
|
|
def button_post(self):
|
|
# Post moves
|
|
for closing in self:
|
|
for move_config in closing.move_config_ids.sorted("sequence"):
|
|
move_config.move_id.action_post()
|
|
self.write({"state": "posted"})
|
|
return True
|
|
|
|
def button_open_moves(self):
|
|
# Return an action for showing moves
|
|
return {
|
|
"name": _("Fiscal closing moves"),
|
|
"type": "ir.actions.act_window",
|
|
"view_type": "form",
|
|
"view_mode": "tree,form",
|
|
"res_model": "account.move",
|
|
"domain": [("fyc_id", "in", self.ids)],
|
|
}
|
|
|
|
def button_open_move_lines(self):
|
|
return {
|
|
"name": _("Fiscal closing move lines"),
|
|
"type": "ir.actions.act_window",
|
|
"view_type": "form",
|
|
"view_mode": "tree,form",
|
|
"res_model": "account.move.line",
|
|
"domain": [("move_id.fyc_id", "in", self.ids)],
|
|
}
|
|
|
|
def button_cancel(self):
|
|
self._moves_remove()
|
|
self.write({"state": "cancelled"})
|
|
return True
|
|
|
|
def button_recover(self):
|
|
self.write(
|
|
{
|
|
"state": "draft",
|
|
"calculation_date": False,
|
|
}
|
|
)
|
|
return True
|
|
|
|
def unlink(self):
|
|
if any(x.state not in ("draft", "cancelled") for x in self):
|
|
raise exceptions.UserError(
|
|
_(
|
|
"You can't remove any closing that is not in draft or "
|
|
"cancelled state."
|
|
)
|
|
)
|
|
return super(AccountFiscalyearClosing, self).unlink()
|
|
|
|
|
|
class AccountFiscalyearClosingConfig(models.Model):
|
|
_inherit = "account.fiscalyear.closing.config.abstract"
|
|
_name = "account.fiscalyear.closing.config"
|
|
_order = "sequence asc, id asc"
|
|
_description = "Fiscal year closing configuration"
|
|
|
|
fyc_id = fields.Many2one(
|
|
comodel_name="account.fiscalyear.closing",
|
|
index=True,
|
|
readonly=True,
|
|
string="Fiscal Year Closing",
|
|
required=True,
|
|
ondelete="cascade",
|
|
)
|
|
mapping_ids = fields.One2many(
|
|
comodel_name="account.fiscalyear.closing.mapping",
|
|
inverse_name="fyc_config_id",
|
|
string="Account mappings",
|
|
)
|
|
closing_type_ids = fields.One2many(
|
|
comodel_name="account.fiscalyear.closing.type",
|
|
inverse_name="fyc_config_id",
|
|
string="Closing types",
|
|
)
|
|
date = fields.Date(string="Move date")
|
|
enabled = fields.Boolean(string="Enabled", default=True)
|
|
journal_id = fields.Many2one(required=True)
|
|
move_id = fields.Many2one(comodel_name="account.move", string="Move")
|
|
|
|
_sql_constraints = [
|
|
(
|
|
"code_uniq",
|
|
"unique(code, fyc_id)",
|
|
_("Code must be unique per fiscal year closing!"),
|
|
),
|
|
]
|
|
|
|
def config_inverse_get(self):
|
|
configs = self.env["account.fiscalyear.closing.config"]
|
|
for config in self:
|
|
code = config.inverse and config.inverse.strip()
|
|
if code:
|
|
configs |= self.search(
|
|
[
|
|
("fyc_id", "=", config.fyc_id.id),
|
|
("code", "=", code),
|
|
]
|
|
)
|
|
return configs
|
|
|
|
def closing_type_get(self, account):
|
|
self.ensure_one()
|
|
closing_type = self.closing_type_default
|
|
closing_types = self.closing_type_ids.filtered(
|
|
lambda r: r.account_type_id == account.user_type_id
|
|
)
|
|
if closing_types:
|
|
closing_type = closing_types[0].closing_type
|
|
return closing_type
|
|
|
|
def move_prepare(self, move_lines):
|
|
self.ensure_one()
|
|
description = self.name
|
|
journal_id = self.journal_id.id
|
|
return {
|
|
"ref": description,
|
|
"date": self.date,
|
|
"fyc_id": self.fyc_id.id,
|
|
"closing_type": self.move_type,
|
|
"journal_id": journal_id,
|
|
"line_ids": [(0, 0, m) for m in move_lines],
|
|
}
|
|
|
|
def _mapping_move_lines_get(self):
|
|
move_lines = []
|
|
dest_totals = {}
|
|
# Add balance/unreconciled move lines
|
|
for account_map in self.mapping_ids:
|
|
dest = account_map.dest_account_id
|
|
dest_totals.setdefault(dest, 0)
|
|
src_accounts = self.env["account.account"].search(
|
|
[
|
|
("company_id", "=", self.fyc_id.company_id.id),
|
|
("code", "=ilike", account_map.src_accounts),
|
|
],
|
|
order="code ASC",
|
|
)
|
|
for account in src_accounts:
|
|
closing_type = self.closing_type_get(account)
|
|
balance = False
|
|
if closing_type == "balance":
|
|
# Get all lines
|
|
lines = account_map.account_lines_get(account)
|
|
balance, move_line = account_map.move_line_prepare(account, lines)
|
|
if move_line:
|
|
move_lines.append(move_line)
|
|
elif closing_type == "unreconciled":
|
|
# Get credit and debit grouping by partner
|
|
partners = account_map.account_partners_get(account)
|
|
for partner in partners:
|
|
balance, move_line = account_map.move_line_partner_prepare(
|
|
account, partner
|
|
)
|
|
if move_line:
|
|
move_lines.append(move_line)
|
|
else:
|
|
# Account type has unsupported closing method
|
|
continue
|
|
if dest and balance:
|
|
dest_totals[dest] -= balance
|
|
# Add destination move lines, if any
|
|
for account_map in self.mapping_ids.filtered("dest_account_id"):
|
|
dest = account_map.dest_account_id
|
|
balance = dest_totals.get(dest, 0)
|
|
if not balance:
|
|
continue
|
|
dest_totals[dest] = 0
|
|
move_line = account_map.dest_move_line_prepare(dest, balance)
|
|
if move_line:
|
|
move_lines.append(move_line)
|
|
return move_lines
|
|
|
|
def inverse_move_prepare(self):
|
|
self.ensure_one()
|
|
move_ids = False
|
|
date = self.fyc_id.date_end
|
|
if self.move_type == "opening":
|
|
date = self.fyc_id.date_opening
|
|
config = self.config_inverse_get()
|
|
if config.move_id:
|
|
move_ids = config.move_id._reverse_moves(
|
|
[
|
|
dict(
|
|
date=date,
|
|
journal_id=self.journal_id.id,
|
|
)
|
|
]
|
|
)
|
|
return move_ids.ids
|
|
|
|
def moves_create(self):
|
|
self.ensure_one()
|
|
moves = self.env["account.move"]
|
|
# Prepare one move per configuration
|
|
data = False
|
|
if self.mapping_ids:
|
|
move_lines = self._mapping_move_lines_get()
|
|
data = self.move_prepare(move_lines)
|
|
elif self.inverse:
|
|
move_ids = self.inverse_move_prepare()
|
|
move = moves.browse(move_ids[0])
|
|
move.write({"ref": self.name, "closing_type": self.move_type})
|
|
self.move_id = move.id
|
|
return move, data
|
|
# Create move
|
|
if not data:
|
|
return False, data
|
|
total_debit = sum([x[2]["debit"] for x in data["line_ids"]])
|
|
total_credit = sum([x[2]["credit"] for x in data["line_ids"]])
|
|
if abs(round(total_credit - total_debit, 2)) >= 0.01:
|
|
# the move is not balanced
|
|
return False, data
|
|
move = moves.with_context(journal_id=self.journal_id.id).create(data)
|
|
self.move_id = move.id
|
|
return move, data
|
|
|
|
|
|
class AccountFiscalyearClosingMapping(models.Model):
|
|
_inherit = "account.fiscalyear.closing.mapping.abstract"
|
|
_name = "account.fiscalyear.closing.mapping"
|
|
_description = "Fiscal year closing mapping"
|
|
|
|
fyc_config_id = fields.Many2one(
|
|
comodel_name="account.fiscalyear.closing.config",
|
|
index=True,
|
|
string="Fiscal year closing config",
|
|
readonly=True,
|
|
required=True,
|
|
ondelete="cascade",
|
|
)
|
|
src_accounts = fields.Char(
|
|
string="Source accounts",
|
|
required=True,
|
|
)
|
|
dest_account_id = fields.Many2one(
|
|
comodel_name="account.account",
|
|
string="Destination account",
|
|
)
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
if "dest_account_id" in vals:
|
|
vals["dest_account_id"] = vals["dest_account_id"][0]
|
|
res = super(AccountFiscalyearClosingMapping, self).create(vals)
|
|
return res
|
|
|
|
def write(self, vals):
|
|
if "dest_account_id" in vals:
|
|
vals["dest_account_id"] = vals["dest_account_id"][0]
|
|
res = super(AccountFiscalyearClosingMapping, self).write(vals)
|
|
return res
|
|
|
|
def dest_move_line_prepare(self, dest, balance, partner_id=False):
|
|
self.ensure_one()
|
|
move_line = {}
|
|
precision = self.env["decimal.precision"].precision_get("Account")
|
|
date = self.fyc_config_id.fyc_id.date_end
|
|
if self.fyc_config_id.move_type == "opening":
|
|
date = self.fyc_config_id.fyc_id.date_opening
|
|
if not float_is_zero(balance, precision_digits=precision):
|
|
move_line = {
|
|
"account_id": dest.id,
|
|
"debit": balance < 0 and -balance,
|
|
"credit": balance > 0 and balance,
|
|
"name": _("Result"),
|
|
"date": date,
|
|
"partner_id": partner_id,
|
|
}
|
|
return move_line
|
|
|
|
def move_line_prepare(self, account, account_lines, partner_id=False):
|
|
self.ensure_one()
|
|
move_line = {}
|
|
balance = 0
|
|
precision = self.env["decimal.precision"].precision_get("Account")
|
|
description = self.name or account.name
|
|
date = self.fyc_config_id.fyc_id.date_end
|
|
if self.fyc_config_id.move_type == "opening":
|
|
date = self.fyc_config_id.fyc_id.date_opening
|
|
if account_lines:
|
|
balance = sum(account_lines.mapped("debit")) - sum(
|
|
account_lines.mapped("credit")
|
|
)
|
|
if not float_is_zero(balance, precision_digits=precision):
|
|
move_line = {
|
|
"account_id": account.id,
|
|
"debit": balance < 0 and -balance,
|
|
"credit": balance > 0 and balance,
|
|
"name": description,
|
|
"date": date,
|
|
"partner_id": partner_id,
|
|
}
|
|
else:
|
|
balance = 0
|
|
return balance, move_line
|
|
|
|
def account_lines_get(self, account):
|
|
self.ensure_one()
|
|
start = self.fyc_config_id.fyc_id.date_start
|
|
end = self.fyc_config_id.fyc_id.date_end
|
|
company_id = self.fyc_config_id.fyc_id.company_id.id
|
|
return self.env["account.move.line"].search(
|
|
[
|
|
("company_id", "=", company_id),
|
|
("account_id", "=", account.id),
|
|
("move_id.state", "!=", "cancel"),
|
|
("date", ">=", start),
|
|
("date", "<=", end),
|
|
]
|
|
)
|
|
|
|
def move_line_partner_prepare(self, account, partner):
|
|
self.ensure_one()
|
|
move_line = {}
|
|
balance = partner.get("debit", 0.0) - partner.get("credit", 0.0)
|
|
precision = self.env["decimal.precision"].precision_get("Account")
|
|
description = self.name or account.name
|
|
partner_id = partner.get("partner_id")
|
|
if partner_id:
|
|
partner_id = partner_id[0]
|
|
date = self.fyc_config_id.fyc_id.date_end
|
|
if self.fyc_config_id.move_type == "opening":
|
|
date = self.fyc_config_id.fyc_id.date_opening
|
|
if not float_is_zero(balance, precision_digits=precision):
|
|
move_line = {
|
|
"account_id": account.id,
|
|
"debit": balance < 0 and -balance,
|
|
"credit": balance > 0 and balance,
|
|
"name": description,
|
|
"date": date,
|
|
"partner_id": partner_id,
|
|
}
|
|
else:
|
|
balance = 0
|
|
return balance, move_line
|
|
|
|
def account_partners_get(self, account):
|
|
self.ensure_one()
|
|
start = self.fyc_config_id.fyc_id.date_start
|
|
end = self.fyc_config_id.fyc_id.date_end
|
|
company_id = self.fyc_config_id.fyc_id.company_id.id
|
|
return self.env["account.move.line"].read_group(
|
|
[
|
|
("company_id", "=", company_id),
|
|
("account_id", "=", account.id),
|
|
("date", ">=", start),
|
|
("date", "<=", end),
|
|
],
|
|
["partner_id", "credit", "debit"],
|
|
["partner_id"],
|
|
)
|
|
|
|
|
|
class AccountFiscalyearClosingType(models.Model):
|
|
_inherit = "account.fiscalyear.closing.type.abstract"
|
|
_name = "account.fiscalyear.closing.type"
|
|
_description = "Fiscal year closing type"
|
|
|
|
fyc_config_id = fields.Many2one(
|
|
comodel_name="account.fiscalyear.closing.config",
|
|
index=True,
|
|
string="Fiscal year closing config",
|
|
readonly=True,
|
|
required=True,
|
|
ondelete="cascade",
|
|
)
|