need to test read confirmation mails
This commit is contained in:
@@ -20,13 +20,14 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import ssl
|
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from email.message import Message
|
from email.message import Message
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
import socks
|
import socks
|
||||||
|
import sys
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from imapclient import IMAPClient
|
from imapclient import IMAPClient
|
||||||
|
|
||||||
@@ -90,7 +91,10 @@ PROXY_TYPE_MAP = {
|
|||||||
"HTTP": socks.HTTP,
|
"HTTP": socks.HTTP,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.addHandler(logging.StreamHandler(stream=sys.stdout))
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
@@ -209,12 +213,31 @@ class ProxyIMAPClient(IMAPClient):
|
|||||||
|
|
||||||
Usage :
|
Usage :
|
||||||
proxy = ProxyConfig(host="127.0.0.1", port=1080, proxy_type="SOCKS5")
|
proxy = ProxyConfig(host="127.0.0.1", port=1080, proxy_type="SOCKS5")
|
||||||
client = ProxyIMAPClient("imap.gmail.com", proxy=proxy, use_uid=True)
|
client = ProxyIMAPClient("imap.gmail.com", proxy=proxy, use_uid=True,
|
||||||
|
subjects=["Confirmation", "Appointment"])
|
||||||
client.login("user@gmail.com", "password")
|
client.login("user@gmail.com", "password")
|
||||||
|
|
||||||
|
Paramètres supplémentaires
|
||||||
|
--------------------------
|
||||||
|
proxy : ProxyConfig
|
||||||
|
Configuration du proxy SOCKS/HTTP.
|
||||||
|
subjects : list[str], optional
|
||||||
|
Sujets (ou sous-chaînes) à utiliser pour filtrer les emails.
|
||||||
|
Accessibles via ``client.subjects``.
|
||||||
|
Utilisés par ``search_by_subjects()`` pour construire
|
||||||
|
automatiquement les critères IMAP SUBJECT.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, host: str, proxy: ProxyConfig, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
proxy: ProxyConfig,
|
||||||
|
subjects: Optional[List[str]] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
self._proxy = proxy
|
self._proxy = proxy
|
||||||
|
# Sujets à rechercher, injectables depuis l'extérieur
|
||||||
|
self.subjects: List[str] = list(subjects) if subjects else []
|
||||||
super().__init__(host, **kwargs)
|
super().__init__(host, **kwargs)
|
||||||
|
|
||||||
def _create_IMAP4(self):
|
def _create_IMAP4(self):
|
||||||
@@ -228,12 +251,59 @@ class ProxyIMAPClient(IMAPClient):
|
|||||||
timeout=getattr(self._timeout, "connect", None),
|
timeout=getattr(self._timeout, "connect", None),
|
||||||
)
|
)
|
||||||
# Connexion non-SSL à travers le proxy (rare, mais supporté)
|
# Connexion non-SSL à travers le proxy (rare, mais supporté)
|
||||||
# On monkey-patch juste la connexion TCP
|
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Connexion IMAP non-SSL via proxy non implémentée. "
|
"Connexion IMAP non-SSL via proxy non implémentée. "
|
||||||
"Utilisez ssl=True (port 993)."
|
"Utilisez ssl=True (port 993)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def search_by_subjects(
|
||||||
|
self,
|
||||||
|
since: Optional[datetime.datetime] = None,
|
||||||
|
extra_criteria: Optional[List] = None,
|
||||||
|
) -> List[int]:
|
||||||
|
"""
|
||||||
|
Recherche les UIDs des emails dont le sujet correspond à l'un
|
||||||
|
des sujets stockés dans ``self.subjects``.
|
||||||
|
|
||||||
|
Si ``self.subjects`` est vide, retourne tous les messages
|
||||||
|
depuis ``since`` (sans filtre par sujet).
|
||||||
|
|
||||||
|
Paramètres
|
||||||
|
----------
|
||||||
|
since : datetime, optional
|
||||||
|
Filtre SINCE (aujourd'hui par défaut).
|
||||||
|
extra_criteria : list, optional
|
||||||
|
Critères IMAP supplémentaires à combiner (AND implicite).
|
||||||
|
|
||||||
|
Retourne
|
||||||
|
--------
|
||||||
|
list[int] — UIDs correspondants (peut être vide).
|
||||||
|
|
||||||
|
Exemple
|
||||||
|
-------
|
||||||
|
client.subjects = ["Confirmation RDV", "confirmed"]
|
||||||
|
uids = client.search_by_subjects(since=datetime.datetime.today())
|
||||||
|
"""
|
||||||
|
since = since or datetime.datetime.today()
|
||||||
|
base: List = ["SINCE", since]
|
||||||
|
if extra_criteria:
|
||||||
|
base.extend(extra_criteria)
|
||||||
|
|
||||||
|
if not self.subjects:
|
||||||
|
return self.search(base)
|
||||||
|
|
||||||
|
# Construire OR enchaîné : OR SUBJECT "A" (OR SUBJECT "B" SUBJECT "C")
|
||||||
|
# IMAPClient accepte des listes imbriquées pour les OR
|
||||||
|
def _build_or(subjects: List[str]) -> List:
|
||||||
|
if len(subjects) == 1:
|
||||||
|
return ["SUBJECT", subjects[0]]
|
||||||
|
return ["OR", ["SUBJECT", subjects[0]], _build_or(subjects[1:])]
|
||||||
|
|
||||||
|
subject_filter = _build_or(self.subjects)
|
||||||
|
# Combiner avec les critères de base (AND implicite dans IMAP)
|
||||||
|
criteria = base + subject_filter
|
||||||
|
return self.search(criteria)
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# Fonctions utilitaires
|
# Fonctions utilitaires
|
||||||
@@ -288,6 +358,14 @@ class ProxyMailReader:
|
|||||||
Configuration du proxy.
|
Configuration du proxy.
|
||||||
timeout : float, optional
|
timeout : float, optional
|
||||||
Timeout de connexion en secondes (défaut : 30 s).
|
Timeout de connexion en secondes (défaut : 30 s).
|
||||||
|
subjects : list[str], optional
|
||||||
|
Liste de sujets (ou sous-chaînes) à rechercher dans les emails.
|
||||||
|
Si None ou vide, on utilise les sujets Hermès par défaut
|
||||||
|
(VALIDATION_URL_SUBJECT_FR et VALIDATION_URL_SUBJECT_EN).
|
||||||
|
Les sujets fournis s'ajoutent aux critères par défaut (OR).
|
||||||
|
from_addresses : list[str], optional
|
||||||
|
Liste d'adresses expéditeur à accepter en complément.
|
||||||
|
Si None ou vide, on conserve uniquement "no-reply@hermes.com".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -295,10 +373,19 @@ class ProxyMailReader:
|
|||||||
account: MailAccount,
|
account: MailAccount,
|
||||||
proxy: ProxyConfig,
|
proxy: ProxyConfig,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
|
subjects: Optional[List[str]] = None,
|
||||||
|
from_addresses: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
self.account = account
|
self.account = account
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self._subjects = []
|
||||||
|
if subjects:
|
||||||
|
self._subjects.extend(subjects)
|
||||||
|
# Adresses expéditeur acceptées
|
||||||
|
self._from_addresses: List[str] = ["no-reply@hermes.com"]
|
||||||
|
if from_addresses:
|
||||||
|
self._from_addresses.extend(from_addresses)
|
||||||
|
|
||||||
# ── Connexion ────────────────────────────────────────────
|
# ── Connexion ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -311,12 +398,13 @@ class ProxyMailReader:
|
|||||||
client = ProxyIMAPClient(
|
client = ProxyIMAPClient(
|
||||||
host=imap_server,
|
host=imap_server,
|
||||||
proxy=self.proxy,
|
proxy=self.proxy,
|
||||||
|
subjects=self._subjects, # propagation des sujets vers le client bas niveau
|
||||||
use_uid=True,
|
use_uid=True,
|
||||||
ssl=True,
|
ssl=True,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
client.login(self.account.login, self.account.password)
|
client.login(self.account.login, self.account.password)
|
||||||
logger.info("[%s] Connecté.", self.account.login)
|
logger.info("[%s] Connecté. Sujets recherchés : %s", self.account.login, self._subjects)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
# ── Lecture des dossiers ─────────────────────────────────
|
# ── Lecture des dossiers ─────────────────────────────────
|
||||||
@@ -343,7 +431,8 @@ class ProxyMailReader:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
try:
|
try:
|
||||||
uids = client.search(["SINCE", since])
|
# Utilise les sujets injectés dans client pour filtrer dès la requête IMAP
|
||||||
|
uids = client.search_by_subjects(since=since)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("[%s] Recherche échouée dans '%s' : %s",
|
logger.warning("[%s] Recherche échouée dans '%s' : %s",
|
||||||
self.account.login, folder, exc)
|
self.account.login, folder, exc)
|
||||||
@@ -366,11 +455,10 @@ class ProxyMailReader:
|
|||||||
from_addr = em.get("From", "")
|
from_addr = em.get("From", "")
|
||||||
to_addr = em.get("To", self.account.login)
|
to_addr = em.get("To", self.account.login)
|
||||||
|
|
||||||
# Filtrer : on ne garde que les emails de validation Hermes
|
# Filtrer : on ne garde que les emails correspondant aux sujets/expéditeurs configurés
|
||||||
is_validation = (
|
is_validation = (
|
||||||
VALIDATION_URL_SUBJECT_FR in subject
|
any(s in subject for s in self._subjects)
|
||||||
or VALIDATION_URL_SUBJECT_EN in subject
|
or any(addr in from_addr.lower() for addr in self._from_addresses)
|
||||||
or "no-reply@hermes.com" in from_addr.lower()
|
|
||||||
)
|
)
|
||||||
if not is_validation:
|
if not is_validation:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import email
|
import email
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
from builtins import list
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from email.message import Message
|
from email.message import Message
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from builtins import list
|
||||||
from imapclient import IMAPClient
|
from imapclient import IMAPClient
|
||||||
|
|
||||||
from src.db.mirgration.migration_tools import migre_accepted_appointment
|
from src.db.mirgration.migration_tools import migre_accepted_appointment
|
||||||
from src.db.mongo_manager import MONGO_STORE_MANAGER
|
from src.db.mongo_manager import MONGO_STORE_MANAGER
|
||||||
|
from src.mail.imap_proxy_reader import ProxyMailReader, MailAccount, ProxyConfig
|
||||||
from src.mail.mail_constants import create_imap, show_folders, is_gmx_address
|
from src.mail.mail_constants import create_imap, show_folders, is_gmx_address
|
||||||
from src.mail.mail_reader import get_gmx_proxy_config
|
from src.mail.mail_reader import get_gmx_proxy_config
|
||||||
from src.mail.imap_proxy_reader import ProxyMailReader, MailAccount, ProxyConfig
|
|
||||||
from src.notification.AcceptedResultPojo import get_accepted_result_from
|
from src.notification.AcceptedResultPojo import get_accepted_result_from
|
||||||
from src.notification.mailer import Mailer
|
from src.notification.mailer import Mailer
|
||||||
from src.pojo.ResultEnum import ResultEnum
|
from src.pojo.mail.mail_pojo import MailPojo
|
||||||
from src.pojo.mail.mail_pojo import MailPojo, MailAddress
|
|
||||||
|
|
||||||
CONFIRMATION_SUBJECT_FR = 'Votre=20rendez-vous=20est=20confirm=C3'
|
CONFIRMATION_SUBJECT_FR = 'Votre=20rendez-vous=20est=20confirm=C3'
|
||||||
CONFIRMATION_SUBJECT_EN = 'confirmed'
|
CONFIRMATION_SUBJECT_EN = 'confirmed'
|
||||||
@@ -28,9 +27,15 @@ date_format = "%d-%b-%Y" # DD-Mon-YYYY e.g., 3-Mar-2014
|
|||||||
FRENCH_CONFIRMED_MESSAGE = "Nous aurons le plaisir de vous accueillir"
|
FRENCH_CONFIRMED_MESSAGE = "Nous aurons le plaisir de vous accueillir"
|
||||||
|
|
||||||
|
|
||||||
def read_gmx_proxy_confirmation_emails(mail, mails_messages: list, proxy_config: ProxyConfig) -> None:
|
def read_gmx_proxy_confirmation_emails(
|
||||||
|
mail,
|
||||||
|
mails_messages: list,
|
||||||
|
proxy_config: ProxyConfig,
|
||||||
|
subjects: list = None,
|
||||||
|
) -> None:
|
||||||
account = MailAccount(login=mail.mail, password=mail.password)
|
account = MailAccount(login=mail.mail, password=mail.password)
|
||||||
results = ProxyMailReader(account, proxy_config).read(since=datetime.datetime.today())
|
reader = ProxyMailReader(account, proxy_config, subjects=subjects)
|
||||||
|
results = reader.read(since=datetime.datetime.today())
|
||||||
for result in results:
|
for result in results:
|
||||||
mail_pojo = MailPojo(subject=result.subject, body=result.body, from_address=result.from_address)
|
mail_pojo = MailPojo(subject=result.subject, body=result.body, from_address=result.from_address)
|
||||||
mail_pojo.mail_address = mail.mail
|
mail_pojo.mail_address = mail.mail
|
||||||
@@ -160,22 +165,28 @@ def accept_appointment_found(accepted_result_list: list):
|
|||||||
_all_contact_list = MONGO_STORE_MANAGER.get_all_contact_to_book_list()
|
_all_contact_list = MONGO_STORE_MANAGER.get_all_contact_to_book_list()
|
||||||
_all_register_account = MONGO_STORE_MANAGER.get_all_registered_users()
|
_all_register_account = MONGO_STORE_MANAGER.get_all_registered_users()
|
||||||
mailer = Mailer()
|
mailer = Mailer()
|
||||||
# sginal = SignalSender()
|
|
||||||
print(accepted_result_list)
|
print(accepted_result_list)
|
||||||
for reserve in accepted_result_list:
|
for reserve in accepted_result_list:
|
||||||
result = get_accepted_result_from(reserve, MONGO_STORE_MANAGER, _all_contact_list)
|
result = get_accepted_result_from(reserve, MONGO_STORE_MANAGER, _all_contact_list)
|
||||||
for user in _all_register_account:
|
for user in _all_register_account:
|
||||||
if user.mail == result.email:
|
if user.mail == result.email:
|
||||||
result.account_password = user.password
|
result.account_password = user.password
|
||||||
mailer.send_email(result, to_all=False)
|
# mailer.send_email(result, to_all=False)
|
||||||
MONGO_STORE_MANAGER.update_reserve_result(reserve.id, ResultEnum.ACCEPTED, reserve.message)
|
# MONGO_STORE_MANAGER.update_reserve_result(reserve.id, ResultEnum.ACCEPTED, reserve.message)
|
||||||
# sginal.send_result(result)
|
|
||||||
|
|
||||||
if len(accepted_result_list) > 0:
|
if len(accepted_result_list) > 0:
|
||||||
migre_accepted_appointment(str(datetime.date.today()))
|
migre_accepted_appointment(str(datetime.date.today()))
|
||||||
|
|
||||||
|
|
||||||
def find_confirmation_contacts_for_today():
|
def find_confirmation_contacts_for_today(mode: str = 'default'):
|
||||||
|
"""
|
||||||
|
Retourne la liste des boîtes mail à scanner pour aujourd'hui.
|
||||||
|
|
||||||
|
Modes disponibles :
|
||||||
|
- 'default' : comportement habituel (exclut les adresses outlook.com)
|
||||||
|
- 'all' : toutes les adresses liées aux rendez-vous du jour (y compris outlook)
|
||||||
|
- 'gmx_only' : uniquement les adresses GMX liées aux rendez-vous du jour
|
||||||
|
"""
|
||||||
_all_mail_list = MONGO_STORE_MANAGER.get_destination_emails()
|
_all_mail_list = MONGO_STORE_MANAGER.get_destination_emails()
|
||||||
_all_appointments_today = MONGO_STORE_MANAGER.get_all_successful_items_for_day()
|
_all_appointments_today = MONGO_STORE_MANAGER.get_all_successful_items_for_day()
|
||||||
if len(_all_appointments_today) == 0:
|
if len(_all_appointments_today) == 0:
|
||||||
@@ -184,24 +195,28 @@ def find_confirmation_contacts_for_today():
|
|||||||
for _item in _all_appointments_today:
|
for _item in _all_appointments_today:
|
||||||
for _mail in _all_mail_list:
|
for _mail in _all_mail_list:
|
||||||
if _mail.mail == _item.mail:
|
if _mail.mail == _item.mail:
|
||||||
|
if mode == 'all':
|
||||||
|
_mail_list_to_scan.append(_mail)
|
||||||
|
elif mode == 'gmx_only':
|
||||||
|
if is_gmx_address(_mail.mail):
|
||||||
|
_mail_list_to_scan.append(_mail)
|
||||||
|
else: # 'default'
|
||||||
# do not need to scan outlook
|
# do not need to scan outlook
|
||||||
if "outlook.com" not in _mail.mail:
|
if "outlook.com" not in _mail.mail:
|
||||||
# if _item.url_validated is True:
|
|
||||||
_mail_list_to_scan.append(_mail)
|
_mail_list_to_scan.append(_mail)
|
||||||
break
|
break
|
||||||
print("Found {} emails to scan".format(len(_mail_list_to_scan)))
|
print("Found {} emails to scan (mode={})".format(len(_mail_list_to_scan), mode))
|
||||||
return _mail_list_to_scan
|
return _mail_list_to_scan
|
||||||
|
|
||||||
|
|
||||||
def find_confirmation_contacts_mail_list(mail_list):
|
def find_confirmation_contacts_mail_list(mail_list, subjects: list = None):
|
||||||
mail_list.append(MailAddress("saigecong1990@pissmail.com", "cvExXKOP8oY1D@"))
|
|
||||||
mails_messages = []
|
mails_messages = []
|
||||||
gmx_proxy_config = get_gmx_proxy_config()
|
gmx_proxy_config = get_gmx_proxy_config()
|
||||||
# read all the emails
|
# read all the emails
|
||||||
with ThreadPoolExecutor(max_workers=200) as executor:
|
with ThreadPoolExecutor(max_workers=200) as executor:
|
||||||
for mail in mail_list:
|
for mail in mail_list:
|
||||||
if is_gmx_address(mail.mail) and gmx_proxy_config is not None:
|
if is_gmx_address(mail.mail) and gmx_proxy_config is not None:
|
||||||
executor.submit(read_gmx_proxy_confirmation_emails, mail, mails_messages, gmx_proxy_config)
|
executor.submit(read_gmx_proxy_confirmation_emails, mail, mails_messages, gmx_proxy_config, subjects)
|
||||||
else:
|
else:
|
||||||
mail_reader = MailConfirmationReader(mail.mail, mail.password)
|
mail_reader = MailConfirmationReader(mail.mail, mail.password)
|
||||||
executor.submit(mail_reader.read_emails, mails_messages)
|
executor.submit(mail_reader.read_emails, mails_messages)
|
||||||
@@ -210,7 +225,7 @@ def find_confirmation_contacts_mail_list(mail_list):
|
|||||||
successful_items = MONGO_STORE_MANAGER.get_all_successful_items_for_day()
|
successful_items = MONGO_STORE_MANAGER.get_all_successful_items_for_day()
|
||||||
# check the hours
|
# check the hours
|
||||||
current_hour = datetime.datetime.now().hour
|
current_hour = datetime.datetime.now().hour
|
||||||
if current_hour < 15:
|
if current_hour < 22:
|
||||||
# add yesterday's appointment only for morning
|
# add yesterday's appointment only for morning
|
||||||
successful_items.extend(MONGO_STORE_MANAGER.get_all_successful_items_for_yesterday())
|
successful_items.extend(MONGO_STORE_MANAGER.get_all_successful_items_for_yesterday())
|
||||||
for mail in mails_messages:
|
for mail in mails_messages:
|
||||||
@@ -238,12 +253,12 @@ def find_confirmation_contacts_mail_list(mail_list):
|
|||||||
return accepted_appointment_list
|
return accepted_appointment_list
|
||||||
|
|
||||||
|
|
||||||
def read_mails_and_find_confirmation_contacts(all_mails=False):
|
def read_mails_and_find_confirmation_contacts(all_mails=False, mode: str = 'default', subjects: list = None):
|
||||||
if all_mails:
|
if all_mails:
|
||||||
mail_list = MONGO_STORE_MANAGER.get_destination_emails()
|
mail_list = MONGO_STORE_MANAGER.get_destination_emails()
|
||||||
else:
|
else:
|
||||||
mail_list = find_confirmation_contacts_for_today()
|
mail_list = find_confirmation_contacts_for_today(mode=mode)
|
||||||
return find_confirmation_contacts_mail_list(mail_list)
|
return find_confirmation_contacts_mail_list(mail_list, subjects=subjects)
|
||||||
|
|
||||||
|
|
||||||
# init_logger()
|
# init_logger()
|
||||||
@@ -252,8 +267,8 @@ logger.addHandler(logging.StreamHandler(stream=sys.stdout))
|
|||||||
# check whether the url has already been clicked
|
# check whether the url has already been clicked
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# read_mails_and_find_confirmation_contacts()
|
# read_mails_and_find_confirmation_contacts()
|
||||||
_mail_list_today = find_confirmation_contacts_for_today()
|
_mail_list_today = find_confirmation_contacts_for_today(mode="gmx_only")
|
||||||
# print("size is {}".format(len(_mail_list_today)))
|
# print("size is {}".format(len(_mail_list_today)))
|
||||||
find_confirmation_contacts_mail_list(_mail_list_today)
|
find_confirmation_contacts_mail_list(_mail_list_today, subjects=[CONFIRMATION_SUBJECT_FR, CONFIRMATION_SUBJECT_EN])
|
||||||
# _items = MONGO_STORE_MANAGER.get_all_successful_items_for_day()
|
# _items = MONGO_STORE_MANAGER.get_all_successful_items_for_day()
|
||||||
# accept_appointment_found([random.choice(_items)])
|
# accept_appointment_found([random.choice(_items)])
|
||||||
|
|||||||
Reference in New Issue
Block a user