From e1b0773fcb763e0d831630beda7694de7c1484dd Mon Sep 17 00:00:00 2001 From: Gelson do Souto Date: Wed, 11 Mar 2026 19:50:52 +0000 Subject: [PATCH] primeiro commit --- i18n_ao_nif_validator/CHANGELOG.md | 0 i18n_ao_nif_validator/README.md | 0 i18n_ao_nif_validator/__init__.py | 2 + i18n_ao_nif_validator/__manifest__.py | 40 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 254 bytes i18n_ao_nif_validator/controllers/__init__.py | 2 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 229 bytes .../__pycache__/main.cpython-313.pyc | Bin 0 -> 4480 bytes i18n_ao_nif_validator/controllers/main.py | 69 ++++ .../data/nif_validator_data.xml | 33 ++ .../data/nif_validator_rules.xml | 38 ++ i18n_ao_nif_validator/models/__init__.py | 3 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 305 bytes .../__pycache__/nif_log.cpython-313.pyc | Bin 0 -> 1824 bytes .../res_config_settings.cpython-313.pyc | Bin 0 -> 1212 bytes .../__pycache__/res_partner.cpython-313.pyc | Bin 0 -> 7819 bytes i18n_ao_nif_validator/models/nif_log.py | 29 ++ .../models/res_config_settings.py | 21 + i18n_ao_nif_validator/models/res_partner.py | 139 +++++++ .../security/ir.model.access.csv | 3 + .../security/nif_validator_security.xml | 22 + .../static/src/css/nif_validator.css | 0 .../static/src/js/nif_validator.js | 0 .../tests/tests_nif_validator.py | 382 ++++++++++++++++++ .../tests/tests_res_partner.py | 252 ++++++++++++ i18n_ao_nif_validator/views/menu.xml | 24 ++ i18n_ao_nif_validator/views/nif_log_view.xml | 88 ++++ .../views/res_config_settings_view.xml | 54 +++ .../views/res_partner_view.xml | 66 +++ 29 files changed, 1267 insertions(+) create mode 100644 i18n_ao_nif_validator/CHANGELOG.md create mode 100644 i18n_ao_nif_validator/README.md create mode 100644 i18n_ao_nif_validator/__init__.py create mode 100644 i18n_ao_nif_validator/__manifest__.py create mode 100644 i18n_ao_nif_validator/__pycache__/__init__.cpython-313.pyc create mode 100644 i18n_ao_nif_validator/controllers/__init__.py create mode 100644 i18n_ao_nif_validator/controllers/__pycache__/__init__.cpython-313.pyc create mode 100644 i18n_ao_nif_validator/controllers/__pycache__/main.cpython-313.pyc create mode 100644 i18n_ao_nif_validator/controllers/main.py create mode 100644 i18n_ao_nif_validator/data/nif_validator_data.xml create mode 100644 i18n_ao_nif_validator/data/nif_validator_rules.xml create mode 100644 i18n_ao_nif_validator/models/__init__.py create mode 100644 i18n_ao_nif_validator/models/__pycache__/__init__.cpython-313.pyc create mode 100644 i18n_ao_nif_validator/models/__pycache__/nif_log.cpython-313.pyc create mode 100644 i18n_ao_nif_validator/models/__pycache__/res_config_settings.cpython-313.pyc create mode 100644 i18n_ao_nif_validator/models/__pycache__/res_partner.cpython-313.pyc create mode 100644 i18n_ao_nif_validator/models/nif_log.py create mode 100644 i18n_ao_nif_validator/models/res_config_settings.py create mode 100644 i18n_ao_nif_validator/models/res_partner.py create mode 100644 i18n_ao_nif_validator/security/ir.model.access.csv create mode 100644 i18n_ao_nif_validator/security/nif_validator_security.xml create mode 100644 i18n_ao_nif_validator/static/src/css/nif_validator.css create mode 100644 i18n_ao_nif_validator/static/src/js/nif_validator.js create mode 100644 i18n_ao_nif_validator/tests/tests_nif_validator.py create mode 100644 i18n_ao_nif_validator/tests/tests_res_partner.py create mode 100644 i18n_ao_nif_validator/views/menu.xml create mode 100644 i18n_ao_nif_validator/views/nif_log_view.xml create mode 100644 i18n_ao_nif_validator/views/res_config_settings_view.xml create mode 100644 i18n_ao_nif_validator/views/res_partner_view.xml diff --git a/i18n_ao_nif_validator/CHANGELOG.md b/i18n_ao_nif_validator/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/README.md b/i18n_ao_nif_validator/README.md new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/__init__.py b/i18n_ao_nif_validator/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/i18n_ao_nif_validator/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/i18n_ao_nif_validator/__manifest__.py b/i18n_ao_nif_validator/__manifest__.py new file mode 100644 index 0000000..33e73d0 --- /dev/null +++ b/i18n_ao_nif_validator/__manifest__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Angola NIF Validator (AGT)', + 'author': 'Hilari Tech LDA', + 'version': '18.0.1.0.0', + 'category': 'Accounting/Localizations', + 'summary': 'Validação automática do NIF angolano via API da AGT', + 'description': """ +Angola NIF Validator (AGT) +========================== +Integração com o endpoint oficial da AGT para validação de NIF. +""", + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'contacts', + 'mail', + 'base_setup', # ADICIONADO: Necessário para as Configurações Gerais + ], + 'data': [ + 'security/nif_validator_security.xml', + 'security/ir.model.access.csv', + 'data/nif_validator_data.xml', + 'views/nif_log_view.xml', # Recomendado carregar o log antes do parceiro se houver campos relacionais + 'views/res_partner_view.xml', + 'views/res_config_settings_view.xml', + 'views/menu.xml', + # 'data/nif_validator_rules.xml', # Certifica-te que este ficheiro existe ou comenta se estiver vazio + ], + 'assets': { + 'web.assets_backend': [ + 'l10n_ao_nif_validator/static/src/css/nif_validator.css', + 'l10n_ao_nif_validator/static/src/js/nif_validator.js', + ], + }, + 'images': ['static/description/icon.png'], + 'installable': True, + 'application': True, # Podes colocar True se quiseres que apareça na lista principal de Apps + 'auto_install': False, +} \ No newline at end of file diff --git a/i18n_ao_nif_validator/__pycache__/__init__.cpython-313.pyc b/i18n_ao_nif_validator/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e9958fc72eb0380dcbcabeff663391366ab0f957 GIT binary patch literal 254 zcmXwzze)o^5XNWkf`1ay*#s=oUSTHbQizq6f);7)mgROe3wt{#*?EPT=r}%^ZT;L5huu->gqgc%cE`$p%=I~Tj zqi-dVN_9sKSr?Mfq^6jeLU5e!s&5d+cXBB`+TgDER2~i8 S(nRF9R7!oq*%!?CSo{GPU_#yi literal 0 HcmV?d00001 diff --git a/i18n_ao_nif_validator/controllers/__init__.py b/i18n_ao_nif_validator/controllers/__init__.py new file mode 100644 index 0000000..cd4d6a8 --- /dev/null +++ b/i18n_ao_nif_validator/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import main \ No newline at end of file diff --git a/i18n_ao_nif_validator/controllers/__pycache__/__init__.cpython-313.pyc b/i18n_ao_nif_validator/controllers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cb991e44eecbb795abb18452ce33231dc706920 GIT binary patch literal 229 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 literal 0 HcmV?d00001 diff --git a/i18n_ao_nif_validator/controllers/main.py b/i18n_ao_nif_validator/controllers/main.py new file mode 100644 index 0000000..da80047 --- /dev/null +++ b/i18n_ao_nif_validator/controllers/main.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +import logging +from odoo import http, _, fields +from odoo.http import request +from odoo.exceptions import ValidationError, UserError + +_logger = logging.getLogger(__name__) + + +class NifValidatorController(http.Controller): + _ROUTE = '/web/nif/validate' + + @http.route(_ROUTE, type='json', auth='user', methods=['POST'], csrf=True) + def validate_nif(self, nif=None, partner_id=None, **kwargs): + if not nif: + return self._error(_('O campo NIF é obrigatório.')) + + nif = str(nif).strip() + _logger.info('NIF Validator: requisição recebida | nif=%s | partner_id=%s', nif, partner_id) + + try: + partner = self._resolve_partner(partner_id) + result = self._run_validation(partner, nif) + return result + except (ValidationError, UserError) as exc: + return self._error(str(exc.args[0])) + except Exception as exc: + _logger.exception('NIF Validator: erro inesperado') + return self._error(_('Ocorreu um erro interno. Contacte o administrador.')) + + def _resolve_partner(self, partner_id): + Partner = request.env['res.partner'] + if not partner_id: + return Partner.browse([]) + try: + partner_id = int(partner_id) + except (TypeError, ValueError): + raise UserError(_('ID de contacto inválido.')) + + partner = Partner.browse(partner_id) + if not partner.exists(): + raise UserError(_('O contacto com ID %d não foi encontrado.') % partner_id) + return partner + + # ESTA É A FUNÇÃO QUE DEVES SUBSTITUIR + def _run_validation(self, partner, nif): + if partner: + # Atualiza o NIF e chama a lógica do modelo Python (res_partner.py) + partner.sudo().write({'nif_ao': nif}) + partner.action_validate_nif() + + if partner.nif_ao_validated: + return self._success({ + 'nif': partner.nif_ao, + 'nome': partner.name, + 'estado': partner.nif_ao_state or '', + 'tipo_contribuinte': partner.nif_ao_contributor_type or '', + 'ultima_validacao': fields.Datetime.to_string(partner.nif_ao_last_validation), + }) + else: + return self._error(_('Não foi possível validar o NIF %s na AGT.') % nif) + else: + return self._error(_('Para validar o NIF, o contacto deve estar previamente criado.')) + + def _success(self, data): + return {'success': True, 'data': data} + + def _error(self, message): + return {'success': False, 'error': str(message)} \ No newline at end of file diff --git a/i18n_ao_nif_validator/data/nif_validator_data.xml b/i18n_ao_nif_validator/data/nif_validator_data.xml new file mode 100644 index 0000000..8b07ae2 --- /dev/null +++ b/i18n_ao_nif_validator/data/nif_validator_data.xml @@ -0,0 +1,33 @@ + + + + + + + l10n_ao_nif_validator.agt_endpoint_url + https://validador.agt.minfin.gov.ao + + + + + l10n_ao_nif_validator.agt_request_timeout + 3 + + + + + l10n_ao_nif_validator.agt_auto_validate + True + + + + + l10n_ao_nif_validator.agt_enable_logs + True + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/data/nif_validator_rules.xml b/i18n_ao_nif_validator/data/nif_validator_rules.xml new file mode 100644 index 0000000..7246a03 --- /dev/null +++ b/i18n_ao_nif_validator/data/nif_validator_rules.xml @@ -0,0 +1,38 @@ + + + + + + + Log NIF: acesso por empresa (utilizador) + + + + ['|', + ('partner_id.company_id', '=', False), + ('partner_id.company_id', '=', company_id) + ] + + + + + + + + + + Log NIF: acesso total (gestor) + + + [(1, '=', 1)] + + + + + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/__init__.py b/i18n_ao_nif_validator/models/__init__.py new file mode 100644 index 0000000..af3e031 --- /dev/null +++ b/i18n_ao_nif_validator/models/__init__.py @@ -0,0 +1,3 @@ +from . import nif_log +from . import res_partner +from . import res_config_settings \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/__pycache__/__init__.cpython-313.pyc b/i18n_ao_nif_validator/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c64874b867bc59c9a462d7ee2711a38226154cf GIT binary patch literal 305 zcmXw#u}T9$5Qb-OFBp{Ah=pD3yuz&MQizq6fQ9s!(?&{9IyL#eCGU_%ztEmFkCUb!3tL55gr7IY2u7IqwJR@ zvm`l%Ixj|Fg4+wxA30p>#@CD|WP{!sip~<-vt&!})>!LcV?;eOS}1FD|~ z)c_Bv!JNXow!)i<|2unY2q~m?sbRHy&d(#jxzdpnaTz`y1;$7xMh>Yxj}$f9R_X=b zcqg73Yb(Wpajhexx6McZ<9bI%Uz?G-lo4+;27ocyQKP@j7z9S=oD*ttPT@nB;wcv& zz7!vD@sUgMD=vOj9ps~>q0F#|=jrx4nn88Vrp#PtrI#>ZsG2b8c6RsNo;6s>Ndo|c zv`*5V$I&-OUo*MQuPmqUn6 zyueIbE846eN6xZ2HA^BadCWBSgdb!x0f4$Jr&uJGCAw5c-Wk}sO=jb5=9s$E2XW

{>l%<4}s>U{nRbilzUR59Fc?^82-Lg72@4s2d zWE9>lEs;Dc6VFtQdCyBPQ>&;M)&2slu(YfTr|&w{w8^(9pBWQjj7_afFcuMv;pKD; zxsGD|zM~m!qz~h5$}QWVCNUYnLDvUZvJaeAWbz_}8P{d;F*SDxr#6=j7vT-f+?!@5 z5s`L#Fx5q1VMpV_55pk3RbqJc6G2GOCZcNv!YhGPe8;vcSQ;G*7vakxg3AWeY|*1b z!Q7IUEfFY?4{Z^1i;v6DMyQ;Ka_s{QGf`ot1&VJlW)RI35o+PHrbo|UEW?Oh?4TZ^ z_k8QcbOGCY6(R<(8x}2M&2}ql6ANRWgp5z&MB+(q2YQj)r7(s3l^tqmoaPo;(J2$t zwsH@3#&T1$+1oiyhoOY*9Lr{9D@UhhO{_5)5^ldHjOVt+i4kjkWsP@r}8|#b#=yw$VxsH|Fc}2kbD=OfEq<5W;wU z{9x~gnP&g%wbhdl0vE}6ubG?%+!Hw1Z>DCzd*Ev0asBc0zUIJeEq^ZlS~E5O@dm$r zmUuoB6afZx4c-JQ$Du2L6D?gUwtt(Tg_>diULKtBA76%)axy&%EDr)ZN{I;JdQ>mVHej^d&yD zzD!d%ngih(@{niUMa!17mQyn2pgcN{ywouA(rZ>Z+q(4UeSf~-C#8IRpk#Kq2CT)0QwBAA!&I?raVigy_B@qGUcx0^hAar(w^;QuBK!gF#7(> z$a-xJMmsPD|I5gE?F~lfsf-SIYR!0{zQsA$1|(Ib zN<&dnO-*g!Vfmip3+E>Hw*=$bnZLDKyBN+)b4�Yy_5%+~E){3Xwm2^9zN@JbeiiRNKM-9&huMb`c zXFk%Rdhk-S3aMQ>-3$R*D`Hw3ny+ZKL!?ut0aIEyK6UPiK-DHsw?H{r4=E0T_6Ds$ zIdd3u$O&W9fia9p1X^smFy4+x*wpl5yuqZ>A>)(_Q0Fk?n^ZC_ZAiq}i+YJ%6;Gq;m*vKrd{Iuf-78!pn_H9vYFX`XTwe2 zj|(G)o1@SgvC`HP8Nb8E77wB#uVo^*Roj>6G`rG{ZUOk0-q361}-`>pKr Q?8p4n`~1{T1c%1mU#@swod5s; literal 0 HcmV?d00001 diff --git a/i18n_ao_nif_validator/models/__pycache__/res_partner.cpython-313.pyc b/i18n_ao_nif_validator/models/__pycache__/res_partner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..439fbb9755a1df94d5e8ddcacf7488f7929f93a3 GIT binary patch literal 7819 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 D;A5j$ literal 0 HcmV?d00001 diff --git a/i18n_ao_nif_validator/models/nif_log.py b/i18n_ao_nif_validator/models/nif_log.py new file mode 100644 index 0000000..d366542 --- /dev/null +++ b/i18n_ao_nif_validator/models/nif_log.py @@ -0,0 +1,29 @@ +from odoo import models, fields + + +class NifValidationLog(models.Model): + _name = 'nif.validation.log' + _description = 'Log de Validação de NIF Angola' + _order = 'create_date desc' + _inherit = ['mail.thread'] # Para chatter funcionar como no teu XML + + partner_id = fields.Many2one('res.partner', string="Contacto", readonly=True) + nif = fields.Char(string="NIF", readonly=True) + state = fields.Selection([ + ('success', 'Sucesso'), + ('not_found', 'Não Encontrado'), + ('error', 'Erro'), + ('connection_error', 'Erro de Ligação') + ], string="Estado", readonly=True) + + http_status_code = fields.Integer(string="Código HTTP", readonly=True) + duration_ms = fields.Float(string="Duração (ms)", readonly=True) + error_message = fields.Text(string="Mensagem de Erro", readonly=True) + raw_response = fields.Text(string="Resposta JSON", readonly=True) + is_success = fields.Boolean(string="Sucesso", readonly=True) + + # Campos da resposta AGT + response_nif = fields.Char(string="NIF (AGT)") + response_name = fields.Char(string="Nome (AGT)") + response_state = fields.Char(string="Estado Fiscal") + response_type = fields.Char(string="Tipo Contribuinte") \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/res_config_settings.py b/i18n_ao_nif_validator/models/res_config_settings.py new file mode 100644 index 0000000..e1c5cee --- /dev/null +++ b/i18n_ao_nif_validator/models/res_config_settings.py @@ -0,0 +1,21 @@ +from odoo import models, fields + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + nif_agt_endpoint_url = fields.Char( + string="URL do Endpoint AGT", + config_parameter='l10n_ao_nif_validator.agt_endpoint_url' + ) + nif_agt_request_timeout = fields.Integer( + string="Timeout (segundos)", + config_parameter='l10n_ao_nif_validator.agt_request_timeout' + ) + nif_agt_auto_validate = fields.Boolean( + string="Validação Automática", + config_parameter='l10n_ao_nif_validator.agt_auto_validate' + ) + nif_agt_enable_logs = fields.Boolean( + string="Activar Registo de Logs", + config_parameter='l10n_ao_nif_validator.agt_enable_logs' + ) \ No newline at end of file diff --git a/i18n_ao_nif_validator/models/res_partner.py b/i18n_ao_nif_validator/models/res_partner.py new file mode 100644 index 0000000..2f54d32 --- /dev/null +++ b/i18n_ao_nif_validator/models/res_partner.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- + +import requests +import re +import urllib3 +import logging +from datetime import datetime + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + # 1. CAMPOS + nif_ao = fields.Char(string="NIF Angola", copy=False) + nif_ao_validated = fields.Boolean(string="NIF Validado", default=False, copy=False, readonly=True) + nif_ao_state = fields.Char(string="Estado Fiscal (AGT)", readonly=True) + nif_ao_contributor_type = fields.Char(string="Tipo de Contribuinte", readonly=True) + nif_ao_inadimplente = fields.Char(string="Inadimplente", readonly=True) + nif_ao_regime_iva = fields.Char(string="Regime de IVA", readonly=True) + nif_ao_last_validation = fields.Datetime(string="Última Validação", readonly=True) + nif_ao_validation_count = fields.Integer(string="Consultas", compute='_compute_nif_logs_count') + + # Contagem dos logs para mostrar no botão inteligente + def _compute_nif_logs_count(self): + for record in self: + if self.env.get('nif.validation.log'): + record.nif_ao_validation_count = self.env['nif.validation.log'].search_count([ + ('partner_id', '=', record.id) + ]) + else: + record.nif_ao_validation_count = 0 + + # Abre a janela do histórico + def action_open_nif_logs(self): + self.ensure_one() + return { + 'name': _('Logs de Consulta NIF'), + 'type': 'ir.actions.act_window', + 'res_model': 'nif.validation.log', + 'view_mode': 'list,form', + 'domain': [('partner_id', '=', self.id)], + 'target': 'current', + } + + # NOVO: Função para apagar o histórico de consultas deste cliente + def action_clear_nif_logs(self): + self.ensure_one() + if self.env.get('nif.validation.log'): + logs = self.env['nif.validation.log'].search([('partner_id', '=', self.id)]) + logs.unlink() # Apaga os registos da base de dados + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Histórico Apagado'), + 'message': _('Todo o histórico de consultas deste NIF foi apagado.'), + 'type': 'warning', + } + } + + # 2. LÓGICA DE EXTRAÇÃO (API AGT) + @api.onchange('nif_ao') + def _onchange_nif_ao_fetch_agt(self): + if not self.nif_ao or len(self.nif_ao.strip()) < 9: + return + + nif = self.nif_ao.strip() + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + url = f"https://portaldocontribuinte.minfin.gov.ao/consultar-nif-do-contribuinte?nif={nif}" + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/122.0.0.0'} + + try: + _logger.info(">>> A consultar AGT para NIF: %s", nif) + response = requests.get(url, headers=headers, timeout=15, verify=False) + + if response.status_code == 200 and response.text: + html = response.text + clean_text = re.sub(r'<[^>]+>', ' ', html) + clean_text = re.sub(r'\s+', ' ', clean_text).strip() + + nome_m = re.search(r'Nome:\s*(.*?)\s*Tipo:', clean_text, re.IGNORECASE) + tipo_m = re.search(r'Tipo:\s*(.*?)\s*Estado:', clean_text, re.IGNORECASE) + est_m = re.search(r'Estado:\s*(.*?)\s*Inadimplente:', clean_text, re.IGNORECASE) + inad_m = re.search(r'Inadimplente:\s*(.*?)\s*Regime de IVA:', clean_text, re.IGNORECASE) + iva_m = re.search(r'Regime de IVA:\s*(.*?)\s*Residente Fiscal', clean_text, re.IGNORECASE) + + if nome_m: + self.name = nome_m.group(1).strip() + if tipo_m: + self.nif_ao_contributor_type = tipo_m.group(1).strip() + if est_m: + self.nif_ao_state = est_m.group(1).strip() + if inad_m: + self.nif_ao_inadimplente = inad_m.group(1).strip() + if iva_m: + self.nif_ao_regime_iva = iva_m.group(1).strip() + + self.nif_ao_validated = True + self.nif_ao_last_validation = fields.Datetime.now() + + except Exception as e: + _logger.error("Erro na busca: %s", str(e)) + + # 3. AÇÃO DO BOTÃO "CONSULTAR" (Agora regista os Logs a sério!) + def action_validate_nif(self): + self.ensure_one() + if not self.nif_ao: + raise UserError(_("Por favor, preencha o campo NIF Angola primeiro.")) + + # 1. Faz a pesquisa no portal da AGT + self._onchange_nif_ao_fetch_agt() + + # 2. Se a pesquisa correu bem, cria o registo no Histórico (Log) + if self.nif_ao_validated: + if self.env.get('nif.validation.log'): + self.env['nif.validation.log'].create({ + 'partner_id': self.id, + 'nif': self.nif_ao, + 'state': 'Sucesso', + 'raw_response': f"Nome: {self.name} | Estado: {self.nif_ao_state} | IVA: {self.nif_ao_regime_iva}" + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Sucesso'), + 'message': _('Dados da AGT consultados com sucesso e registados no histórico.'), + 'type': 'success', + } + } + else: + raise UserError(_("Não foi possível aceder à AGT. Verifique o NIF.")) \ No newline at end of file diff --git a/i18n_ao_nif_validator/security/ir.model.access.csv b/i18n_ao_nif_validator/security/ir.model.access.csv new file mode 100644 index 0000000..5dc33a1 --- /dev/null +++ b/i18n_ao_nif_validator/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_nif_log_user,nif.validation.log.user,model_nif_validation_log,group_nif_ao_user,1,0,0,0 +access_nif_log_manager,nif.validation.log.manager,model_nif_validation_log,group_nif_ao_manager,1,1,1,1 \ No newline at end of file diff --git a/i18n_ao_nif_validator/security/nif_validator_security.xml b/i18n_ao_nif_validator/security/nif_validator_security.xml new file mode 100644 index 0000000..3cffd4d --- /dev/null +++ b/i18n_ao_nif_validator/security/nif_validator_security.xml @@ -0,0 +1,22 @@ + + + + Validação NIF Angola (AGT) + Controle de níveis de acesso para consulta de NIFs na AGT. + 90 + + + + Utilizador + + + + + Gestor + + + + + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/static/src/css/nif_validator.css b/i18n_ao_nif_validator/static/src/css/nif_validator.css new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/static/src/js/nif_validator.js b/i18n_ao_nif_validator/static/src/js/nif_validator.js new file mode 100644 index 0000000..e69de29 diff --git a/i18n_ao_nif_validator/tests/tests_nif_validator.py b/i18n_ao_nif_validator/tests/tests_nif_validator.py new file mode 100644 index 0000000..25a5805 --- /dev/null +++ b/i18n_ao_nif_validator/tests/tests_nif_validator.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +""" +Testes unitarios — integracao com a API AGT +Foco: logica de chamada HTTP, tratamento de erros e registo de logs + +Todos os testes que tocam a rede usam unittest.mock.patch para interceptar +requests.get e simular respostas sem precisar de conectividade real. +""" + +import json +from unittest.mock import patch, MagicMock + +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError, ValidationError + + +# Caminho completo do metodo a fazer mock dentro do modulo res.partner +_REQUESTS_GET = 'odoo.addons.l10n_ao_nif_validator.models.res_partner.requests.get' + + +def _mock_response(status_code=200, json_data=None, raise_exc=None): + """ + Fabrica um objeto mock que imita requests.Response. + + :param status_code: codigo HTTP a retornar + :param json_data: dict retornado por response.json() + :param raise_exc: excecao a lancar ao chamar requests.get (timeout, etc.) + """ + mock = MagicMock() + mock.status_code = status_code + if json_data is not None: + mock.json.return_value = json_data + if raise_exc: + mock.side_effect = raise_exc + return mock + + +class TestNifFormatValidation(TransactionCase): + """ + Grupo 1 — Validacao de formato local (sem chamada HTTP) + Testa o metodo estatico _is_valid_nif_format diretamente. + """ + + def setUp(self): + super().setUp() + self.Partner = self.env['res.partner'] + + # --- Formatos validos ------------------------------------------------------- + + def test_valid_nif_10_digits(self): + """NIF com exactamente 10 digitos deve ser valido.""" + self.assertTrue(self.Partner._is_valid_nif_format('5001234567')) + + def test_valid_nif_starts_with_zero(self): + """NIF iniciado por zero deve ser aceite (e uma string, nao inteiro).""" + self.assertTrue(self.Partner._is_valid_nif_format('0001234567')) + + # --- Formatos invalidos ----------------------------------------------------- + + def test_invalid_nif_too_short(self): + """NIF com menos de 10 digitos deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500123')) + + def test_invalid_nif_too_long(self): + """NIF com mais de 10 digitos deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('50012345678')) + + def test_invalid_nif_with_letters(self): + """NIF com letras deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500123456A')) + + def test_invalid_nif_with_spaces(self): + """NIF com espacos deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500 123456')) + + def test_invalid_nif_with_dots(self): + """NIF com pontos (formatado) deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('500.123.456')) + + def test_invalid_nif_empty(self): + """NIF vazio deve ser invalido.""" + self.assertFalse(self.Partner._is_valid_nif_format('')) + + def test_invalid_nif_none(self): + """NIF None deve ser invalido sem lancar excecao.""" + self.assertFalse(self.Partner._is_valid_nif_format(None)) + + +class TestAgtApiCall(TransactionCase): + """ + Grupo 2 — Chamada HTTP ao endpoint AGT + Testa _fetch_and_fill_agt_data com respostas simuladas. + """ + + def setUp(self): + super().setUp() + # Configura o URL do endpoint para que os testes nao falhem + # por falta de configuracao + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', + 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_auto_validate', 'True' + ) + + self.partner = self.env['res.partner'].create({ + 'name' : 'Parceiro Teste', + 'nif_ao' : '5001234567', + }) + + # --- Resposta 200 (sucesso) ------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_success_fills_partner_fields(self, mock_get): + """Resposta 200 deve preencher nome, estado e tipo no parceiro.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5001234567', + 'nome' : 'Hilari Tech LDA', + 'estado' : 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + self.partner._fetch_and_fill_agt_data() + + self.assertEqual(self.partner.name, 'Hilari Tech LDA') + self.assertEqual(self.partner.nif_ao_state, 'Ativo') + self.assertEqual(self.partner.nif_ao_contributor_type, 'Empresa') + self.assertTrue(self.partner.nif_ao_validated) + self.assertIsNotNone(self.partner.nif_ao_last_validation) + + @patch(_REQUESTS_GET) + def test_api_success_partial_data(self, mock_get): + """Resposta 200 com campos opcionais em falta nao deve lancar erro.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5001234567', + 'nome': 'Empresa Parcial LDA', + # 'estado' e 'tipo_contribuinte' ausentes intencionalmente + }) + + self.partner._fetch_and_fill_agt_data() + + self.assertEqual(self.partner.name, 'Empresa Parcial LDA') + self.assertTrue(self.partner.nif_ao_validated) + + # --- Resposta 404 (nao encontrado) ------------------------------------------ + + @patch(_REQUESTS_GET) + def test_api_404_partner_not_validated(self, mock_get): + """Resposta 404 nao deve marcar o parceiro como validado.""" + mock_get.return_value = _mock_response(404) + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Outros codigos de erro HTTP ------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_500_partner_not_validated(self, mock_get): + """Resposta 500 nao deve marcar o parceiro como validado.""" + mock_get.return_value = _mock_response(500) + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Timeout ---------------------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_timeout_partner_not_validated(self, mock_get): + """Timeout na requisicao nao deve marcar o parceiro como validado.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.Timeout() + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Erro de conexao -------------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_connection_error_partner_not_validated(self, mock_get): + """Erro de conexao nao deve marcar o parceiro como validado.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.ConnectionError("recusada") + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Resposta JSON invalida ------------------------------------------------- + + @patch(_REQUESTS_GET) + def test_api_invalid_json_partner_not_validated(self, mock_get): + """Resposta 200 com JSON invalido nao deve marcar o parceiro como validado.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.side_effect = ValueError("JSON invalido") + mock_resp.text = "erro" + mock_get.return_value = mock_resp + + self.partner._fetch_and_fill_agt_data() + + self.assertFalse(self.partner.nif_ao_validated) + + # --- Endpoint nao configurado ----------------------------------------------- + + def test_missing_endpoint_raises_user_error(self): + """Sem URL configurado deve lancar UserError.""" + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', '' + ) + + with self.assertRaises(UserError): + self.partner._fetch_and_fill_agt_data() + + +class TestNifValidationLogs(TransactionCase): + """ + Grupo 3 — Registo de logs (nif.validation.log) + Verifica que os logs sao criados correctamente em cada cenario. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + + self.partner = self.env['res.partner'].create({ + 'name' : 'Parceiro Log Teste', + 'nif_ao' : '5009876543', + }) + self.NifLog = self.env['nif.validation.log'] + + @patch(_REQUESTS_GET) + def test_log_created_on_success(self, mock_get): + """Um log com state='success' deve ser criado apos validacao bem-sucedida.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Teste Log LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + count_before = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'success'), + ]) + + self.partner._fetch_and_fill_agt_data() + + count_after = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'success'), + ]) + + self.assertEqual(count_after, count_before + 1) + + @patch(_REQUESTS_GET) + def test_log_created_on_404(self, mock_get): + """Um log com state='not_found' deve ser criado quando AGT retorna 404.""" + mock_get.return_value = _mock_response(404) + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'not_found'), + ], limit=1) + + self.assertTrue(bool(log), "Log 'not_found' nao foi criado") + + @patch(_REQUESTS_GET) + def test_log_created_on_timeout(self, mock_get): + """Um log com state='timeout' deve ser criado em caso de timeout.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.Timeout() + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'timeout'), + ], limit=1) + + self.assertTrue(bool(log), "Log 'timeout' nao foi criado") + + @patch(_REQUESTS_GET) + def test_log_created_on_connection_error(self, mock_get): + """Um log com state='connection_error' deve ser criado em erros de rede.""" + import requests as req_lib + mock_get.side_effect = req_lib.exceptions.ConnectionError() + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'connection_error'), + ], limit=1) + + self.assertTrue(bool(log), "Log 'connection_error' nao foi criado") + + @patch(_REQUESTS_GET) + def test_no_log_when_logs_disabled(self, mock_get): + """Nenhum log deve ser criado quando agt_enable_logs=False.""" + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'False' + ) + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Sem Log LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + count_before = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ]) + + self.partner._fetch_and_fill_agt_data() + + count_after = self.NifLog.search_count([ + ('partner_id', '=', self.partner.id), + ]) + + self.assertEqual(count_after, count_before, "Log criado com logs desactivados") + + @patch(_REQUESTS_GET) + def test_log_contains_nif_and_partner(self, mock_get): + """O log de sucesso deve conter o NIF consultado e o partner correcto.""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Dados Log LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('nif', '=', '5009876543'), + ('state', '=', 'success'), + ], limit=1) + + self.assertTrue(bool(log)) + self.assertEqual(log.nif, '5009876543') + self.assertEqual(log.partner_id, self.partner) + self.assertEqual(log.response_name, 'Dados Log LDA') + + @patch(_REQUESTS_GET) + def test_log_duration_ms_recorded(self, mock_get): + """O log deve conter a duracao da chamada em milissegundos (>= 0).""" + mock_get.return_value = _mock_response(200, { + 'nif' : '5009876543', + 'nome': 'Duracao LDA', + 'estado': 'Ativo', + 'tipo_contribuinte': 'Empresa', + }) + + self.partner._fetch_and_fill_agt_data() + + log = self.NifLog.search([ + ('partner_id', '=', self.partner.id), + ('state', '=', 'success'), + ], order='create_date desc', limit=1) + + self.assertGreaterEqual(log.duration_ms, 0) \ No newline at end of file diff --git a/i18n_ao_nif_validator/tests/tests_res_partner.py b/i18n_ao_nif_validator/tests/tests_res_partner.py new file mode 100644 index 0000000..65e4700 --- /dev/null +++ b/i18n_ao_nif_validator/tests/tests_res_partner.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +""" +Testes de integracao — modelo res.partner +Foco: campos NIF, accoes do formulario, validacao manual e reset de campos. +""" + +from unittest.mock import patch, MagicMock + +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError, ValidationError + + +_REQUESTS_GET = 'odoo.addons.l10n_ao_nif_validator.models.res_partner.requests.get' + + +def _agt_ok(nif='5001234567', nome='Empresa Teste LDA', + estado='Ativo', tipo='Empresa'): + """Atalho para uma resposta AGT bem-sucedida.""" + mock = MagicMock() + mock.status_code = 200 + mock.json.return_value = { + 'nif' : nif, + 'nome' : nome, + 'estado' : estado, + 'tipo_contribuinte': tipo, + } + return mock + + +class TestResPartnerFields(TransactionCase): + """ + Grupo 1 — Campos NIF no modelo res.partner + Verifica que os campos existem e tem os defaults correctos. + """ + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Teste Campos'}) + + def test_nif_ao_field_exists(self): + """Campo nif_ao deve existir no modelo.""" + self.assertIn('nif_ao', self.env['res.partner']._fields) + + def test_nif_ao_validated_default_false(self): + """nif_ao_validated deve ser False por defeito.""" + self.assertFalse(self.partner.nif_ao_validated) + + def test_nif_ao_state_default_empty(self): + """nif_ao_state deve estar vazio por defeito.""" + self.assertFalse(self.partner.nif_ao_state) + + def test_nif_ao_contributor_type_default_empty(self): + """nif_ao_contributor_type deve estar vazio por defeito.""" + self.assertFalse(self.partner.nif_ao_contributor_type) + + def test_nif_ao_last_validation_default_empty(self): + """nif_ao_last_validation deve estar vazio por defeito.""" + self.assertFalse(self.partner.nif_ao_last_validation) + + def test_nif_ao_validation_count_default_zero(self): + """nif_ao_validation_count deve ser 0 para parceiro sem historico.""" + self.assertEqual(self.partner.nif_ao_validation_count, 0) + + +class TestResPartnerValidation(TransactionCase): + """ + Grupo 2 — Accao action_validate_nif (botao manual) + Verifica comportamento correcto com e sem NIF preenchido. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + + def test_action_validate_nif_raises_if_no_nif(self): + """action_validate_nif deve lancar UserError se nif_ao estiver vazio.""" + partner = self.env['res.partner'].create({'name': 'Sem NIF'}) + with self.assertRaises(UserError): + partner.action_validate_nif() + + def test_action_validate_nif_raises_if_format_invalid(self): + """action_validate_nif deve lancar ValidationError se formato invalido.""" + partner = self.env['res.partner'].create({ + 'name' : 'NIF Invalido', + 'nif_ao': '123', + }) + with self.assertRaises(ValidationError): + partner.action_validate_nif() + + @patch(_REQUESTS_GET) + def test_action_validate_nif_returns_notification(self, mock_get): + """action_validate_nif deve retornar uma accao de notificacao.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Com NIF', + 'nif_ao': '5001234567', + }) + result = partner.action_validate_nif() + self.assertIsInstance(result, dict) + self.assertEqual(result.get('type'), 'ir.actions.client') + self.assertEqual(result.get('tag'), 'display_notification') + + @patch(_REQUESTS_GET) + def test_action_validate_nif_success_sets_validated(self, mock_get): + """Validacao bem-sucedida deve definir nif_ao_validated=True.""" + mock_get.return_value = _agt_ok(nome='Hilari Tech LDA') + partner = self.env['res.partner'].create({ + 'name' : 'Antes da Validacao', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertTrue(partner.nif_ao_validated) + + @patch(_REQUESTS_GET) + def test_partner_name_updated_from_agt(self, mock_get): + """O nome do parceiro deve ser actualizado com o nome retornado pela AGT.""" + mock_get.return_value = _agt_ok(nome='Novo Nome AGT LDA') + partner = self.env['res.partner'].create({ + 'name' : 'Nome Antigo', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertEqual(partner.name, 'Novo Nome AGT LDA') + + @patch(_REQUESTS_GET) + def test_partner_state_filled_from_agt(self, mock_get): + """nif_ao_state deve ser preenchido com o estado retornado pela AGT.""" + mock_get.return_value = _agt_ok(estado='Ativo') + partner = self.env['res.partner'].create({ + 'name' : 'Estado Teste', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertEqual(partner.nif_ao_state, 'Ativo') + + @patch(_REQUESTS_GET) + def test_partner_type_filled_from_agt(self, mock_get): + """nif_ao_contributor_type deve ser preenchido com o tipo retornado pela AGT.""" + mock_get.return_value = _agt_ok(tipo='Empresa') + partner = self.env['res.partner'].create({ + 'name' : 'Tipo Teste', + 'nif_ao': '5001234567', + }) + partner.action_validate_nif() + self.assertEqual(partner.nif_ao_contributor_type, 'Empresa') + + +class TestResPartnerResetFields(TransactionCase): + """ + Grupo 3 — Reset de campos ao alterar o NIF + Verifica que _reset_nif_fields limpa os dados anteriores. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'False' + ) + + @patch(_REQUESTS_GET) + def test_reset_clears_validated_flag(self, mock_get): + """_reset_nif_fields deve limpar nif_ao_validated.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Reset Teste', + 'nif_ao': '5001234567', + }) + partner._fetch_and_fill_agt_data() + self.assertTrue(partner.nif_ao_validated) + + # Simula alteracao do NIF — chama o reset + partner._reset_nif_fields() + self.assertFalse(partner.nif_ao_validated) + + @patch(_REQUESTS_GET) + def test_reset_clears_state_and_type(self, mock_get): + """_reset_nif_fields deve limpar nif_ao_state e nif_ao_contributor_type.""" + mock_get.return_value = _agt_ok(estado='Ativo', tipo='Empresa') + partner = self.env['res.partner'].create({ + 'name' : 'Reset State Tipo', + 'nif_ao': '5001234567', + }) + partner._fetch_and_fill_agt_data() + self.assertTrue(partner.nif_ao_state) + + partner._reset_nif_fields() + self.assertFalse(partner.nif_ao_state) + self.assertFalse(partner.nif_ao_contributor_type) + + +class TestResPartnerLogLink(TransactionCase): + """ + Grupo 4 — Ligacao entre res.partner e nif.validation.log + Verifica o One2many e o smart button de contagem. + """ + + def setUp(self): + super().setUp() + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_endpoint_url', 'https://mock.agt.test' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_request_timeout', '3' + ) + self.env['ir.config_parameter'].sudo().set_param( + 'l10n_ao_nif_validator.agt_enable_logs', 'True' + ) + + @patch(_REQUESTS_GET) + def test_validation_count_increments(self, mock_get): + """Cada validacao deve incrementar nif_ao_validation_count.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Contador', + 'nif_ao': '5001234567', + }) + + self.assertEqual(partner.nif_ao_validation_count, 0) + partner._fetch_and_fill_agt_data() + self.assertEqual(partner.nif_ao_validation_count, 1) + partner._fetch_and_fill_agt_data() + self.assertEqual(partner.nif_ao_validation_count, 2) + + @patch(_REQUESTS_GET) + def test_action_open_nif_logs_returns_window(self, mock_get): + """action_open_nif_logs deve retornar um act_window filtrado pelo partner.""" + mock_get.return_value = _agt_ok() + partner = self.env['res.partner'].create({ + 'name' : 'Abrir Logs', + 'nif_ao': '5001234567', + }) + partner._fetch_and_fill_agt_data() + + result = partner.action_open_nif_logs() + + self.assertEqual(result['type'], 'ir.actions.act_window') + self.assertEqual(result['res_model'], 'nif.validation.log') + self.assertIn(('partner_id', '=', partner.id), result['domain']) \ No newline at end of file diff --git a/i18n_ao_nif_validator/views/menu.xml b/i18n_ao_nif_validator/views/menu.xml new file mode 100644 index 0000000..6674515 --- /dev/null +++ b/i18n_ao_nif_validator/views/menu.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/views/nif_log_view.xml b/i18n_ao_nif_validator/views/nif_log_view.xml new file mode 100644 index 0000000..d44cb55 --- /dev/null +++ b/i18n_ao_nif_validator/views/nif_log_view.xml @@ -0,0 +1,88 @@ + + + + + nif.validation.log.list + nif.validation.log + + + + + + + + + + + + + nif.validation.log.kanban + nif.validation.log + + + + + + + +

+
+ NIF: +
+
Nome:
+
Data:
+
+ +
+
+ + + + + + + + nif.validation.log.form + nif.validation.log + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ + + Logs de Validação NIF + nif.validation.log + list,kanban,form + + + \ No newline at end of file diff --git a/i18n_ao_nif_validator/views/res_config_settings_view.xml b/i18n_ao_nif_validator/views/res_config_settings_view.xml new file mode 100644 index 0000000..bd84e5c --- /dev/null +++ b/i18n_ao_nif_validator/views/res_config_settings_view.xml @@ -0,0 +1,54 @@ + + + + + res.config.settings.view.nif.ao + res.config.settings + + + + + + + + +
+ Parâmetros de integração com o endpoint oficial da Administração Geral Tributária (AGT) de Angola. +
+ +
+ + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/i18n_ao_nif_validator/views/res_partner_view.xml b/i18n_ao_nif_validator/views/res_partner_view.xml new file mode 100644 index 0000000..9540bb1 --- /dev/null +++ b/i18n_ao_nif_validator/views/res_partner_view.xml @@ -0,0 +1,66 @@ + + + + + res.partner.form.nif.ao + res.partner + + + + + + + + + + + + + + + res.partner.list.nif.ao + res.partner + + + + + + + + + + \ No newline at end of file