Skip to content
Commits on Source (56)
......@@ -74,3 +74,4 @@ Matthieu Weber <mweber+weboob@free.fr> <matthieu+weboob@weber.fi.eu.org>
Matthieu Weber <mweber+weboob@free.fr> <mweber@free.fr>
Théo Dorée <theo.doree@budget-insight.com> <tdoree@budget-insight.com>
Tenma <nicolas.gattolin@budget-insight.com>
Damien Ramelet <damien.ramelet@budget-insight.com>
......@@ -24,7 +24,7 @@
from base64 import b64encode
from woob.browser.browsers import APIBrowser
from woob.exceptions import BrowserIncorrectPassword, BrowserBanned
from woob.exceptions import BrowserIncorrectPassword, ScrapingBlocked
from woob.capabilities.captcha import (
ImageCaptchaJob, RecaptchaJob, RecaptchaV3Job, RecaptchaV2Job, FuncaptchaJob, HcaptchaJob,
CaptchaError, InsufficientFunds, UnsolvableCaptcha, InvalidCaptcha,
......@@ -123,8 +123,8 @@ def check_reply(self, r):
'ERROR_RECAPTCHA_INVALID_DOMAIN': InvalidCaptcha,
'ERROR_ZERO_BALANCE': InsufficientFunds,
'ERROR_CAPTCHA_UNSOLVABLE': UnsolvableCaptcha,
'ERROR_IP_BLOCKED': BrowserBanned,
'ERROR_PROXY_BANNED': BrowserBanned,
'ERROR_IP_BLOCKED': ScrapingBlocked,
'ERROR_PROXY_BANNED': ScrapingBlocked,
}
if not r['errorId']:
......
......@@ -21,7 +21,7 @@
from io import BytesIO
from woob.exceptions import BrowserBanned, ActionNeeded, BrowserUnavailable
from woob.exceptions import BrowserUserBanned, ActionNeeded, BrowserUnavailable
from woob.browser.pages import HTMLPage, RawPage, JsonPage, PartialHTMLPage
from woob.browser.filters.json import Dict
from woob.browser.filters.standard import CleanText
......@@ -109,7 +109,7 @@ def on_load(self):
class PredisconnectedPage(HTMLPage):
def on_load(self):
raise BrowserBanned()
raise BrowserUserBanned()
class DeniedPage(HTMLPage):
......
......@@ -32,6 +32,7 @@
from woob.exceptions import BrowserIncorrectPassword, BrowserUnavailable
from woob.browser.exceptions import HTTPNotFound, ClientError, ServerError
from woob.browser.pages import FormNotFound
from woob.browser import LoginBrowser, URL, need_login
from woob.capabilities.bank import Account, AccountOwnership, Loan
from woob.capabilities.base import NotAvailable, find_object
......@@ -49,6 +50,7 @@
NewLoginPage, JsFilePage, AuthorizePage, LoginTokensPage, VkImagePage,
AuthenticationMethodPage, AuthenticationStepPage, CaissedepargneVirtKeyboard,
AccountsNextPage, GenericAccountsPage, InfoTokensPage, NatixisUnavailablePage,
RedirectErrorPage,
)
from .document_pages import BasicTokenPage, SubscriberPage, SubscriptionsPage, DocumentsPage
from .linebourse_browser import LinebourseAPIBrowser
......@@ -61,6 +63,11 @@ class BrokenPageError(Exception):
pass
class TemporaryBrowserUnavailable(BrowserUnavailable):
# To handle temporary errors that are usually solved just by making a retry
pass
def retry(exc_check, tries=4):
"""Decorate a function to retry several times in case of exception.
......@@ -161,6 +168,7 @@ class BanquePopulaire(LoginBrowser):
r'https://[^/]+/cyber/internet/ContinueTask.do\?.*dialogActionPerformed=SELECTION_ENCOURS_CARTE.*',
r'https://[^/]+/cyber/internet/ContinueTask.do\?.*dialogActionPerformed=SOLDE.*',
r'https://[^/]+/cyber/internet/ContinueTask.do\?.*dialogActionPerformed=CONTRAT.*',
r'https://[^/]+/cyber/internet/ContinueTask.do\?.*dialogActionPerformed=CANCEL.*',
r'https://[^/]+/cyber/internet/ContinueTask.do\?.*ConsultationDetail.*ActionPerformed=BACK.*',
r'https://[^/]+/cyber/internet/StartTask.do\?taskInfoOID=ordreBourseCTJ.*',
r'https://[^/]+/cyber/internet/Page.do\?.*',
......@@ -198,6 +206,12 @@ class BanquePopulaire(LoginBrowser):
)
redirect_page = URL(r'https://[^/]+/portailinternet/_layouts/Ibp.Cyi.Layouts/RedirectSegment.aspx.*', RedirectPage)
redirect_error_page = URL(
r'https://[^/]+/portailinternet/?$',
RedirectErrorPage
)
home_page = URL(
r'https://[^/]+/portailinternet/Catalogue/Segments/.*.aspx(\?vary=(?P<vary>.*))?',
r'https://[^/]+/portailinternet/Pages/.*.aspx\?vary=(?P<vary>.*)',
......@@ -366,6 +380,8 @@ def get_bpcesta(self, cdetab):
'typ_srv': self.user_type,
}
# need to try from the top in that case because this login is a long chain of redirections
@retry(TemporaryBrowserUnavailable)
def do_new_login(self):
# Same login as caissedepargne
url_params = parse_qs(urlparse(self.url).query)
......@@ -516,6 +532,12 @@ def do_redirect(self, headers):
headers=headers,
)
if self.redirect_error_page.is_here() and self.page.is_unavailable():
# website randomly unavailable, need to retry login from the beginning
self.do_logout() # will delete cookies, or we'll always be redirected here
self.location(self.BASEURL)
raise TemporaryBrowserUnavailable()
@retry(BrokenPageError)
@need_login
def go_on_accounts_list(self):
......@@ -797,6 +819,15 @@ def go_investments(self, account, get_account=False):
'token': self.page.build_token(account._params['token']),
}
self.location(self.absurl('/cyber/internet/StartTask.do', base=True), params=params)
try:
# Form to complete the user's info, we can pass it
form = self.page.get_form()
if 'QuestConnCliInt.EcranMessage' in form.get('screenName', ''):
form['dialogActionPerformed'] = 'CANCEL'
form['validationStrategy'] = 'NV'
form.submit()
except FormNotFound:
pass
else:
params = account._invest_params
params['token'] = self.page.build_token(params['token'])
......@@ -923,6 +954,13 @@ def get_invest_history(self, account):
return
self.natixis_history.go(**params)
if self.natixis_unavailable_page.is_here():
# if after this we are not redirected to the NatixisUnavaiblePage it means
# the account is indeed available but there is no history
self.natixis_invest.go(**params)
if self.natixis_unavailable_page.is_here():
raise BrowserUnavailable(self.page.get_message())
return
items_from_json = list(self.page.get_history())
items_from_json.sort(reverse=True, key=lambda item: item.date)
......
......@@ -37,7 +37,9 @@
)
from woob.browser.filters.html import Attr, Link, AttributeNotFound
from woob.browser.filters.json import Dict
from woob.exceptions import BrowserUnavailable, BrowserIncorrectPassword, ActionNeeded
from woob.exceptions import (
ActionNeeded, BrowserUnavailable, BrowserIncorrectPassword,
)
from woob.browser.pages import (
HTMLPage, LoggedPage, FormNotFound, JsonPage, RawPage, XMLPage,
AbstractPage,
......@@ -284,6 +286,11 @@ def on_load(self):
form.submit()
class RedirectErrorPage(HTMLPage):
def is_unavailable(self):
return bool(CleanText('//p[contains(text(), "momentanément indisponible")]')(self.doc))
class ErrorPage(LoggedPage, MyHTMLPage):
def on_load(self):
if CleanText('//script[contains(text(), "momentanément indisponible")]')(self.doc):
......@@ -472,7 +479,7 @@ class MyVirtKeyboard(SplitKeyboard):
'6': 'a80d639443818e838b434c36dd518df5',
'7': '8e59048702e4c5f89bbbc1a598d06d1e',
'8': '46bc59a5b288c63477ff52811a3961c5',
'9': 'a7bf34568154ef91e990aa5bade3e946',
'9': ('a7bf34568154ef91e990aa5bade3e946', '35fd166bc1a4b3529c9f497fa0009da3'),
}
codesep = ' '
......@@ -533,48 +540,82 @@ def virtualkeyboard(self, vk_obj, password):
return MyVirtKeyboard(imgs).get_string_code(password)
def login(self, login, password):
def make_payload_from_password_auth(self, response, password):
form_id = None
for k, v in response['validationUnits'][0].items():
if v[0]['type'] in ('PASSWORD',):
form_id = (k, v[0]['id'], v[0]['type'])
if v[0].get('virtualKeyboard'):
if not password.isdigit():
# Users who get the virtual keyboard needs a password with digits only
raise BrowserIncorrectPassword()
password = self.virtualkeyboard(
vk_obj=v[0]['virtualKeyboard'],
password=password
)
if form_id:
return self.step_make_payload("PASSWORD", password=password, form_id=form_id)
@property
def step_url(self):
return self.request_url + '/step'
def step_make_payload(self, request_type, login=None, password=None, form_id=None):
if form_id is None:
form_id = self.form_id
inner_payload = {
'id': form_id[1],
'type': request_type,
}
if login:
inner_payload['login'] = login.upper()
if password:
inner_payload['password'] = password
payload = {
'validate': {
self.form_id[0]: [{
'id': self.form_id[1],
'login': login.upper(),
'password': password,
'type': 'PASSWORD_LOOKUP',
}],
form_id[0]: [inner_payload],
},
}
url = self.request_url + '/step'
return payload
def login(self, login, password):
if self.form_id[2] == 'IDENTIFIER':
del payload['validate'][self.form_id[0]][0]['password']
payload['validate'][self.form_id[0]][0]['type'] = 'IDENTIFIER'
doc = self.browser.open(url, json=payload).json()
for k, v in doc['validationUnits'][0].items():
if v[0]['type'] in ('PASSWORD',):
form_id = (k, v[0]['id'], v[0]['type'])
if v[0].get('virtualKeyboard'):
if not password.isdigit():
# Users who get the virtual keyboard needs a password with digits only
raise BrowserIncorrectPassword()
password = self.virtualkeyboard(
vk_obj=v[0]['virtualKeyboard'],
password=password
)
# In this state, you send the login and then receive a challenge.
# If you don't like it, you may be able to ask for a fallback challenge.
# Since we only know how to respond to the PASSWORD challenge,
# we ask for a fallback until we get it.
payload = {
'validate': {
form_id[0]: [{
'id': form_id[1],
'password': password,
'type': 'PASSWORD',
}],
},
}
r = self.browser.open(url, json=payload)
identifier_payload = self.step_make_payload('IDENTIFIER', login)
auth_method_resp = self.browser.open(self.step_url, json=identifier_payload).json()
payload = self.make_payload_from_password_auth(auth_method_resp, password)
max_fallback_request = 2
current_fallback_request = 0
# Request new authentification method until we get password
while (not payload
and auth_method_resp.get("phase", {}).get("fallbackFactorAvailable")
and current_fallback_request < max_fallback_request):
current_fallback_request += 1
fallback_payload = {"fallback": {}}
auth_method_resp = self.browser.open(self.step_url, json=fallback_payload).json()
payload = self.make_payload_from_password_auth(auth_method_resp, password)
assert payload, (
"Could not find the password method after %s fallback request" % current_fallback_request
)
else:
payload = self.step_make_payload('PASSWORD_LOOKUP', login, password)
doc = self.browser.open(self.step_url, json=payload).json()
doc = r.json()
self.logger.debug('doc = %s', doc)
if 'phase' in doc and doc['phase']['state'] == 'TERMS_OF_USE':
# Got:
......@@ -585,9 +626,7 @@ def login(self, login, password):
del doc['validationUnits'][0]['LIST_OF_TERMS'][0]['reference']
payload = {'validate': doc['validationUnits'][0]}
url = self.request_url + '/step'
r = self.browser.open(url, json=payload)
doc = r.json()
doc = self.browser.open(self.step_url, json=payload).json()
self.logger.debug('doc = %s', doc)
if 'phase' in doc and doc['phase']['state'] == "ENROLLMENT":
......
......@@ -55,8 +55,9 @@
RecipientsPage, ValidateTransferPage, RegisterTransferPage, AdvisorPage,
AddRecipPage, ActivateRecipPage, ProfilePage, ListDetailCardPage, ListErrorPage,
UselessPage, TransferAssertionError, LoanDetailsPage, TransfersPage, OTPPage,
UnavailablePage,
)
from .document_pages import DocumentsPage, DocumentsResearchPage, TitulairePage, RIBPage
from .document_pages import DocumentsPage, TitulairePage, RIBPage
__all__ = ['BNPPartPro', 'HelloBank']
......@@ -89,9 +90,12 @@ class BNPParibasBrowser(LoginBrowser, StatesMixin):
r'/fr/espace-prive/mot-de-passe-expire',
r'/fr/client/mdp-expire',
r'/fr/client/100-connexion',
r'/fr/systeme/page-indisponible',
ConnectionThresholdPage
)
unavailable_page = URL(
r'/fr/systeme/page-indisponible',
UnavailablePage
)
accounts = URL(r'udc-wspl/rest/getlstcpt', AccountsPage)
loan_details = URL(r'caraccomptes-wspl/rpc/(?P<loan_type>.*)', LoanDetailsPage)
ibans = URL(r'rib-wspl/rpc/comptes', AccountsIBANPage)
......@@ -128,7 +132,7 @@ class BNPParibasBrowser(LoginBrowser, StatesMixin):
titulaire = URL(r'/demat-wspl/rest/listerTitulairesDemat', TitulairePage)
document = URL(r'/demat-wspl/rest/listerDocuments', DocumentsPage)
document_research = URL(r'/demat-wspl/rest/rechercheCriteresDemat', DocumentsResearchPage)
document_research = URL(r'/demat-wspl/rest/modificationTitulaireConsultationDemat', DocumentsPage)
rib_page = URL(r'/rib-wspl/rpc/restituerRIB', RIBPage)
profile = URL(r'/kyc-wspl/rest/informationsClient', ProfilePage)
......@@ -648,67 +652,6 @@ def iter_threads(self):
def get_thread(self, thread):
raise NotImplementedError()
def _fetch_rib_document(self, subscription):
self.rib_page.go(
params={
'contractId': subscription.id,
'i18nSiteType': 'part', # site type value doesn't seem to matter as long as it's present
'i18nLang': 'fr',
'i18nVersion': 'V1',
},
)
if self.rib_page.is_here() and self.page.is_rib_available():
d = Document()
d.id = subscription.id + '_RIB'
d.url = self.page.url
d.type = DocumentTypes.RIB
d.format = 'pdf'
d.label = 'RIB'
return d
@need_login
def iter_documents(self, subscription):
rib = self._fetch_rib_document(subscription)
if rib:
yield rib
titulaires = self.titulaire.go().get_titulaires()
# Calling '/demat-wspl/rest/listerDocuments' before the request on 'document'
# is necessary when you specify an ikpi, otherwise no documents are returned
self.document.go()
docs = []
id_docs = []
iter_documents_functions = [self.page.iter_documents, self.page.iter_documents_pro]
for iter_documents in iter_documents_functions:
for doc in iter_documents(sub_id=subscription.id, sub_number=subscription._number, baseurl=self.BASEURL):
docs.append(doc)
id_docs.append(doc.id)
# documents are sorted by type then date, sort them directly by date
docs = sorted(docs, key=lambda doc: doc.date, reverse=True)
for doc in docs:
yield doc
# When we only have one titulaire, no need to use the ikpi parameter in the request,
# all document are provided with this simple request
data = {
'dateDebut': (datetime.now() - relativedelta(years=3)).strftime('%d/%m/%Y'),
'dateFin': datetime.now().strftime('%d/%m/%Y'),
}
len_titulaires = len(titulaires)
self.logger.info('The total number of titulaires on this connection is %s.', len_titulaires)
# Ikpi is necessary for multi titulaires accounts to get each document of each titulaires
if len_titulaires > 1:
data['ikpiPersonne'] = subscription._iduser
self.document_research.go(json=data)
for doc in self.page.iter_documents(
sub_id=subscription.id, sub_number=subscription._number, baseurl=self.BASEURL
):
if doc.id not in id_docs:
yield doc
@need_login
def iter_subscription(self):
acc_list = self.iter_accounts()
......@@ -750,6 +693,58 @@ def __init__(self, config=None, *args, **kwargs):
def switch(self, subdomain):
self.BASEURL = self.BASEURL_TEMPLATE % subdomain
def _fetch_rib_document(self, subscription):
self.rib_page.go(
params={
'contractId': subscription.id,
'i18nSiteType': 'part', # site type value doesn't seem to matter as long as it's present
'i18nLang': 'fr',
'i18nVersion': 'V1',
},
)
if self.rib_page.is_here() and self.page.is_rib_available():
d = Document()
d.id = subscription.id + '_RIB'
d.url = self.page.url
d.type = DocumentTypes.RIB
d.format = 'pdf'
d.label = 'RIB'
return d
@need_login
def iter_documents(self, subscription):
rib = self._fetch_rib_document(subscription)
if rib:
yield rib
docs = []
id_docs = []
spaces = ('pro.mabanque', 'mabanque')
for space in spaces:
self.switch(space)
# Those 2 requests are needed or we get an error when going on document_research
self.titulaire.go()
self.document.go()
data = {
'numCompte': subscription._number,
}
self.document_research.go(json=data)
iter_documents_functions = [self.page.iter_documents_pro, self.page.iter_documents]
for iter_documents in iter_documents_functions:
for doc in iter_documents(
sub_id=subscription.id, sub_number=subscription._number, baseurl=self.BASEURL
):
if doc.id not in id_docs:
docs.append(doc)
id_docs.append(doc.id)
# documents are sorted by type then date, sort them directly by date
docs = sorted(docs, key=lambda doc: doc.date, reverse=True)
for doc in docs:
yield doc
class HelloBank(BNPParibasBrowser):
BASEURL = 'https://www.hellobank.fr/'
......@@ -53,8 +53,7 @@ def get_document_type(family):
class TitulairePage(LoggedPage, JsonPage):
def get_titulaires(self):
return set([t['idKpiTitulaire'] for t in self.doc['data']['listeTitulairesDemat']['listeTitulaires']])
pass
class ItemDocument(ItemElement):
......@@ -69,8 +68,7 @@ def condition(self):
# the document belong to the subscription.
if 'ibanCrypte' in self.el:
return Env('sub_id')(self) in Dict('ibanCrypte')(self)
else:
return Env('sub_number')(self) in Dict('idContrat', default='')(self)
return Env('sub_number')(self) in Dict('numeroCompteAnonymise', default='')(self)
obj_date = Date(Dict('dateDoc'), dayfirst=True)
obj_format = 'pdf'
......@@ -144,17 +142,6 @@ class item(ItemDocument):
pass
class DocumentsResearchPage(LoggedPage, JsonPage):
@method
class iter_documents(DictElement):
# * refer to the account, it can be 'Comptes chèques', 'Comptes d'épargne', etc...
item_xpath = 'data/rechercheCriteresDemat/*/*/listeDocument'
ignore_duplicate = True
class item(ItemDocument):
pass
class RIBPage(LoggedPage, RawPage):
def is_rib_available(self):
# If the page has no content, it means no RIB can be found
......
......@@ -66,6 +66,10 @@
from woob.tools.html import html2text
class UnavailablePage(HTMLPage):
pass
class TransferAssertionError(Exception):
pass
......
......@@ -22,10 +22,12 @@
from woob.browser import AbstractBrowser, LoginBrowser, URL, need_login
from woob.capabilities.bank import Account
from woob.capabilities.wealth import Per
from woob.exceptions import BrowserIncorrectPassword, BrowserUnavailable, ActionNeeded
from woob.exceptions import BrowserIncorrectPassword, ActionNeeded
from .pages import (
LoginPage, LoginErrorPage, ProfilePage, ErrorPage, AccountPage, AccountSwitchPage,
InvestmentPage, TermPage, UnexpectedPage, HistoryPage,
LoginPage, LoginStep2Page, LoginErrorPage, ProfilePage,
AccountPage, AccountSwitchPage,
InvestmentPage, TermPage, HistoryPage,
)
......@@ -37,10 +39,12 @@ class BnppereBrowser(AbstractBrowser):
class VisiogoBrowser(LoginBrowser):
BASEURL = 'https://visiogo.bnpparibas.com/'
login_page = URL(r'https://authentication.bnpparibas.com/en/Account/Login\?ReturnUrl=https://visiogo.bnpparibas.com/fr-FR', LoginPage)
login_error = URL(r'https://authentication.bnpparibas.com.*ErrorNoValidAffiliation', LoginErrorPage)
error_page = URL(r'https://authentication.bnpparibas.com/en/account/login\?ReturnUrl=.+', ErrorPage)
error_page2 = URL(r'https://authentication.bnpparibas.com/Error\?Code=500', UnexpectedPage)
login_page = URL(r'https://authentication.bnpparibas.com/ind_auth/Account/Login\?ReturnUrl=.+', LoginPage)
login_second_step = URL(
r'https://authentication.bnpparibas.com/ind_auth/connect/authorize/callback',
LoginStep2Page
)
login_error = URL(r'https://authentication.bnpparibas.com/ind_auth/Account/Login$', LoginErrorPage)
term_page = URL(r'/Home/TermsOfUseApproval', TermPage)
account_page = URL(r'/GlobalView/Synthesis', AccountPage)
account_switch = URL(r'/Contract/_ChangeAffiliation', AccountSwitchPage)
......@@ -56,7 +60,7 @@ def __init__(self, config=None, *args, **kwargs):
super(VisiogoBrowser, self).__init__(*args, **kwargs)
def do_login(self):
self.login_page.go()
self.go_home()
self.page.login(self.username, self.password)
if self.login_error.is_here():
......@@ -64,22 +68,16 @@ def do_login(self):
if 'affiliation status' in message:
# 'Your affiliation status no longer allows you to connect to your account.'
raise ActionNeeded(message)
assert False, 'Unknown error on LoginErrorPage: %s.' % message
elif 'incorrect' in message:
raise BrowserIncorrectPassword(message)
raise AssertionError('Unknown error on LoginErrorPage: %s.' % message)
assert self.login_second_step.is_here(), 'Should be on the page of the second step of login'
self.page.send_form()
if self.term_page.is_here():
raise ActionNeeded()
if self.error_page.is_here() or self.error_page2.is_here():
alert = self.page.get_error()
if "account has not been activated" in alert:
raise ActionNeeded(alert)
elif "unexpected" in alert:
raise BrowserUnavailable(alert)
elif "password" in alert:
raise BrowserIncorrectPassword(alert)
else:
assert False
@need_login
def iter_accounts(self):
self.account_page.go()
......
......@@ -38,8 +38,15 @@
class LoginPage(HTMLPage):
def login(self, login, password):
form = self.get_form('//form[@class="form-horizontal"]')
form['Login'] = login
form['Username'] = login
form['Password'] = password
form['button'] = 'login'
form.submit()
class LoginStep2Page(HTMLPage):
def send_form(self):
form = self.get_form()
form.submit()
......@@ -48,13 +55,6 @@ def get_message(self):
return CleanText('//tr[.//img[@class="iconAlert"]]//p')(self.doc)
class ErrorPage(HTMLPage):
def get_error(self):
alert = CleanText('//td/div[@class="editorialContent"]|//div[has-class("blockMaintenance")]/table//p[contains(text(), "password")]')(self.doc)
if alert:
return alert
class ProfilePage(LoggedPage, HTMLPage):
@method
class get_profile(ItemElement):
......@@ -69,13 +69,6 @@ class TermPage(HTMLPage):
pass
class UnexpectedPage(HTMLPage):
def get_error(self):
alert = CleanText('//div[@class="blockMaintenance mainBlock"]/table//td/h3')(self.doc)
if alert:
return alert
class AccountPage(LoggedPage, HTMLPage):
ACCOUNT_TYPES = {
'PER ': Account.TYPE_PER,
......
......@@ -47,7 +47,7 @@ class BoursedirectBrowser(LoginBrowser):
)
history = URL(r'/priv/new/historique-de-compte.php\?ong=3&nc=(?P<nc>\d+)', HistoryPage)
portfolio = URL(r'/fr/page/portefeuille', PortfolioPage)
pre_invests = URL(r'/priv/portefeuille-TR.php\?nc=(?P<nc>\d+)')
pre_invests = URL(r'/priv/new/portefeuille-TR.php\?nc=(?P<nc>\d+)')
invests = URL(r'/streaming/compteTempsReelCK.php\?stream=0', InvestPage)
market_orders = URL(r'/priv/new/ordres-en-carnet.php\?ong=7&nc=(?P<nc>\d+)', MarketOrdersPage)
market_orders_details = URL(r'/priv/new/detailOrdre.php', MarketOrderDetailsPage)
......
......@@ -32,7 +32,7 @@
from woob.browser.exceptions import ServerError, BrowserHTTPNotFound
from woob.capabilities.base import NotAvailable
from woob.exceptions import (
BrowserIncorrectPassword, BrowserBanned, NoAccountsException,
BrowserIncorrectPassword, BrowserUserBanned, NoAccountsException,
BrowserUnavailable, ActionNeeded, NeedInteractiveFor2FA,
BrowserQuestion, AppValidation, AppValidationCancelled, AppValidationExpired,
)
......@@ -459,7 +459,7 @@ def login_without_2fa(self):
if self.badlogin.is_here():
raise BrowserIncorrectPassword()
if self.disabled_account.is_here():
raise BrowserBanned()
raise BrowserUserBanned()
def do_login(self):
self.code = self.config['code'].get()
......@@ -630,14 +630,15 @@ def get_loans(self, account):
if self.par_accounts_loan.is_here():
loan = self.page.get_personal_loan()
# These Loans were not returned before
# So if they repair the precedent behaviour
# we must check where to get them
loan.id = loan.number = account.id
loan.label = account.label
loan.currency = account.currency
loan.url = account.url
loans.append(loan)
if loan is not None:
# These Loans were not returned before
# So if they repair the precedent behaviour
# we must check where to get them
loan.id = loan.number = account.id
loan.label = account.label
loan.currency = account.currency
loan.url = account.url
loans.append(loan)
else:
for loan in self.page.iter_loans():
loan.currency = account.currency
......
......@@ -440,6 +440,9 @@ class CardsJsonDetails(LoggedPage, JsonPage):
class iter_cards(DictElement):
item_xpath = 'cartouchesCarte'
def condition(self):
return self.page.doc.get('userMessage', '') != 'Aucune carte'
class item(ItemElement):
klass = Account
......
......@@ -323,6 +323,10 @@ def obj_url(self):
class get_personal_loan(ItemElement):
klass = Loan
def condition(self):
loan_state = CleanText('//div[div[contains(text(), "Détail de votre")]]/div[4]')(self)
return loan_state != 'Prêt soldé'
obj_balance = CleanDecimal.French('//div[div[contains(text(), "Montant du capital restant")]]/div[4]', sign='-')
obj_total_amount = CleanDecimal.French('//div[div[contains(text(), "Montant emprunté")]]/div[2]')
obj_nb_payments_left = CleanDecimal('//div[div[contains(text(), "mensualités restant à rembourser")]]/div[2]')
......
......@@ -19,6 +19,8 @@
# flake8: compatible
from __future__ import unicode_literals
from woob.browser.filters.standard import CleanText
from woob.browser.pages import HTMLPage
from woob.exceptions import BrowserUnavailable
......
......@@ -23,7 +23,6 @@
import time
import operator
import random
import string
from datetime import date
from decimal import Decimal
......@@ -186,7 +185,8 @@ def init_login(self):
def trigger_connection_twofa(self):
# Needed to record the device doing the SCA and keep it valid.
self.device_id = ''.join(random.choices(string.digits, k=50))
self.device_id = ''.join([str(random.randint(0, 9)) for _ in range(50)]) # Python2 compatible
# self.device_id = ''.join(random.choices(string.digits, k=50)) # better but needs Python3
self.auth_method = self.get_connection_twofa_method()
......
......@@ -46,7 +46,7 @@ class BredModule(Module, CapBankWealth, CapProfile, CapBankTransferAddRecipient)
DESCRIPTION = u'Bred'
LICENSE = 'LGPLv3+'
CONFIG = BackendConfig(
ValueBackendPassword('login', label='Identifiant', masked=False),
ValueBackendPassword('login', label='Identifiant', masked=False, regexp=r'.{,32}'),
ValueBackendPassword('password', label='Mot de passe'),
Value('website', label="Site d'accès", default='bred',
choices={'bred': 'BRED', 'dispobank': 'DispoBank'}),
......
......@@ -76,7 +76,7 @@ class item(ItemElement):
klass = Account
balance_xpath = './/span[contains(text(), "Montant total")]/following-sibling::span'
obj_label = CleanText('./tbody/tr/th//div')
obj_label = CleanText('./tbody/tr/th[1]//div')
obj_balance = CleanDecimal.French(balance_xpath)
obj_currency = Currency(balance_xpath)
obj_type = MapIn(Field('label'), ACCOUNT_TYPES, Account.TYPE_UNKNOWN)
......
......@@ -37,7 +37,7 @@
from .pages import (
LoginPage, PasswordCreationPage, AccountsPage, HistoryPage, SubscriptionPage, InvestmentPage,
InvestmentAccountPage, UselessPage, SSODomiPage, AuthCheckUser, ErrorPage,
InvestmentAccountPage, UselessPage, SSODomiPage, AuthCheckUser, ErrorPage, LoansPage,
)
from ..par.pages import ProfilePage
......@@ -49,6 +49,7 @@ class CmsoProBrowser(LoginBrowser):
r'/domiweb/prive/professionnel/situationGlobaleProfessionnel/0-situationGlobaleProfessionnel.act',
AccountsPage
)
loans = URL(r'/domiweb/prive/particulier/encoursCredit/0-encoursCredit.act', LoansPage)
history = URL(
r'/domiweb/prive/professionnel/situationGlobaleProfessionnel/1-situationGlobaleProfessionnel.act',
HistoryPage
......@@ -219,6 +220,10 @@ def iter_accounts(self):
a._area = area
seen.add(seenkey)
yield a
loans_page = self.go_with_ssodomi(self.loans)
for loan in loans_page.iter_loans():
loan._area = area
yield loan
except ServerError:
self.logger.warning('Area unavailable.')
......@@ -235,7 +240,7 @@ def _build_next_date_range(self, date_range):
@need_login
def iter_history(self, account):
if account._history_url.startswith('javascript:') or account._history_url == '#':
if not account._history_url or account._history_url.startswith('javascript:') or account._history_url == '#':
raise NotImplementedError()
account = find_object(self.iter_accounts(), id=account.id)
......
......@@ -28,9 +28,10 @@
from woob.browser.elements import ListElement, ItemElement, TableElement, method
from woob.browser.filters.standard import (
CleanText, CleanDecimal, DateGuesser, Env, Field, Filter, Regexp, Currency, Date,
Format, Lower,
)
from woob.browser.filters.html import Link, Attr, TableCell
from woob.capabilities.bank import Account
from woob.capabilities.bank import Account, Loan
from woob.capabilities.wealth import Investment
from woob.capabilities.base import NotAvailable
from woob.tools.capabilities.bank.transactions import FrenchTransaction
......@@ -115,6 +116,45 @@ def on_load(self):
raise BrowserIncorrectPassword("Vous n'avez aucun compte sur cet espace. Veuillez choisir un autre type de compte.")
class LoansPage(CMSOPage):
@method
class iter_loans(ListElement):
item_xpath = '//div[@class="master-table"]//li'
class item(ItemElement):
klass = Loan
obj__history_url = None
obj_type = Account.TYPE_LOAN
obj_label = CleanText('./a/span[1]//strong')
obj_maturity_date = Date(
Regexp(CleanText('.//span[contains(@text, "Date de fin")]'), r'Date de fin : (.*)', default=''),
dayfirst=True,
default=NotAvailable
)
obj_balance = CleanDecimal.SI(
'.//i[contains(text(), "Montant restant dû")]/../following-sibling::span[1]',
sign='-'
)
obj_currency = Currency('.//i[contains(text(), "Montant restant dû")]/../following-sibling::span[1]')
obj_next_payment_date = Date(
CleanText('.//i[contains(text(), "Date échéance")]/../following-sibling::span[1]'),
dayfirst=True,
default=NotAvailable
)
obj_next_payment_amount = CleanDecimal.SI(
'.//i[contains(text(), "Montant échéance")]/../following-sibling::span[1]'
)
# There is no actual ID or number for loans
# The credit index is not stable, it's based on javascript code but it's necessary to avoid duplicate IDs
obj_id = Format(
'%s-%s',
Lower('./a/span[1]//strong', replace=[(' ', '_')]),
Regexp(Attr('./a', 'onclick'), r'indCredit, (\d+),'),
)
class InvestmentPage(CMSOPage):
def has_error(self):
return CleanText('//span[@id="id_error_msg"]')(self.doc)
......