first commit
This commit is contained in:
commit
e6c52820cd
227 changed files with 16156 additions and 0 deletions
5
modules/crm/__init__.py/init.py
Normal file
5
modules/crm/__init__.py/init.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from .client import Client
|
||||
from .handler import ClientHandler
|
||||
from .prospect import Prospect
|
||||
from .prospect_handler import ProspectHandler
|
||||
from .cli import main
|
||||
849
modules/crm/cli.py
Normal file
849
modules/crm/cli.py
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
import os
|
||||
from .client import Client
|
||||
from .handler import ClientHandler
|
||||
from .prospect import Prospect
|
||||
from .prospect_handler import ProspectHandler
|
||||
from datetime import date
|
||||
|
||||
def clear_screen():
|
||||
"""Efface l'écran du terminal"""
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
def main():
|
||||
"""Point d'entrée pour l'application CRM"""
|
||||
client_handler = ClientHandler()
|
||||
prospect_handler = ProspectHandler()
|
||||
|
||||
while True:
|
||||
clear_screen()
|
||||
print("=== CRM - Gestion des Relations Clients ===\n")
|
||||
|
||||
print("1. Gestion des Clients")
|
||||
print("2. Gestion des Prospects")
|
||||
print("3. Tableau de bord")
|
||||
print("4. Retour au menu principal")
|
||||
|
||||
choice = input("\nSélectionnez une option (1-4): ")
|
||||
|
||||
if choice == "1":
|
||||
# Gestion des clients
|
||||
manage_clients(client_handler, prospect_handler)
|
||||
elif choice == "2":
|
||||
# Gestion des prospects
|
||||
manage_prospects(client_handler, prospect_handler)
|
||||
elif choice == "3":
|
||||
# Tableau de bord
|
||||
show_dashboard(client_handler, prospect_handler)
|
||||
elif choice == "4":
|
||||
# Retour au menu principal
|
||||
print("\nRetour au menu principal...")
|
||||
break
|
||||
else:
|
||||
print("\nOption invalide. Veuillez réessayer.")
|
||||
|
||||
input("\nAppuyez sur Entrée pour continuer...")
|
||||
|
||||
def display_all_clients(client_handler):
|
||||
"""Affiche tous les clients"""
|
||||
clear_screen()
|
||||
print("=== Liste des Clients ===\n")
|
||||
|
||||
clients = client_handler.get_all_clients()
|
||||
|
||||
if not clients:
|
||||
print("Aucun client trouvé.")
|
||||
return
|
||||
|
||||
for i, client in enumerate(clients, 1):
|
||||
print(f"{i}. {client.name} ({client.company})")
|
||||
print(f" Email: {client.email} | Téléphone: {client.phone}")
|
||||
print(f" Dernière interaction: {client.last_contact}")
|
||||
print(f" Documents: Devis ({len(client.linked_docs['devis'])}) | "
|
||||
f"Propositions ({len(client.linked_docs['propositions'])}) | "
|
||||
f"Factures ({len(client.linked_docs['factures'])})")
|
||||
print("")
|
||||
|
||||
def search_clients(client_handler):
|
||||
"""Recherche des clients par nom"""
|
||||
clear_screen()
|
||||
print("=== Recherche de Clients ===\n")
|
||||
|
||||
search_term = input("Entrez un terme de recherche (nom, email, entreprise): ")
|
||||
|
||||
if not search_term:
|
||||
print("Recherche annulée.")
|
||||
return
|
||||
|
||||
results = []
|
||||
clients = client_handler.get_all_clients()
|
||||
|
||||
for client in clients:
|
||||
if (search_term.lower() in client.name.lower() or
|
||||
search_term.lower() in client.email.lower() or
|
||||
search_term.lower() in client.company.lower()):
|
||||
results.append(client)
|
||||
|
||||
if not results:
|
||||
print(f"Aucun client trouvé pour '{search_term}'.")
|
||||
return
|
||||
|
||||
print(f"\n{len(results)} client(s) trouvé(s):\n")
|
||||
|
||||
for i, client in enumerate(results, 1):
|
||||
print(f"{i}. {client.name} ({client.company})")
|
||||
print(f" Email: {client.email} | Téléphone: {client.phone}")
|
||||
print("")
|
||||
|
||||
# Offrir la possibilité de voir les détails d'un client
|
||||
if results:
|
||||
choice = input("\nEntrez le numéro du client pour voir les détails (ou Entrée pour annuler): ")
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(results):
|
||||
client = results[int(choice) - 1]
|
||||
display_client_details(client)
|
||||
|
||||
def add_new_client(client_handler):
|
||||
"""Ajoute un nouveau client"""
|
||||
clear_screen()
|
||||
print("=== Ajout d'un Nouveau Client ===\n")
|
||||
|
||||
# Recueillir les informations du client
|
||||
name = input("Nom du client: ")
|
||||
if not name:
|
||||
print("Le nom est obligatoire. Opération annulée.")
|
||||
return
|
||||
|
||||
company = input("Entreprise: ")
|
||||
email = input("Email: ")
|
||||
phone = input("Téléphone: ")
|
||||
notes = input("Notes: ")
|
||||
|
||||
# Créer le nouveau client
|
||||
new_client = Client(
|
||||
name=name,
|
||||
company=company,
|
||||
email=email,
|
||||
phone=phone,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
# Ajouter le client
|
||||
client_handler.add_client(new_client)
|
||||
print(f"\nClient '{name}' ajouté avec succès!")
|
||||
|
||||
def edit_client(client_handler):
|
||||
"""Modifie un client existant"""
|
||||
clear_screen()
|
||||
print("=== Modification d'un Client ===\n")
|
||||
|
||||
# Afficher tous les clients pour sélection
|
||||
clients = client_handler.get_all_clients()
|
||||
|
||||
if not clients:
|
||||
print("Aucun client trouvé.")
|
||||
return
|
||||
|
||||
for i, client in enumerate(clients, 1):
|
||||
print(f"{i}. {client.name} ({client.company})")
|
||||
|
||||
# Sélectionner un client à modifier
|
||||
choice = input("\nEntrez le numéro du client à modifier (ou Entrée pour annuler): ")
|
||||
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
|
||||
print("Opération annulée ou sélection invalide.")
|
||||
return
|
||||
|
||||
client = clients[int(choice) - 1]
|
||||
|
||||
# Afficher les détails actuels
|
||||
print(f"\nModification de '{client.name}':")
|
||||
print(f"1. Nom: {client.name}")
|
||||
print(f"2. Entreprise: {client.company}")
|
||||
print(f"3. Email: {client.email}")
|
||||
print(f"4. Téléphone: {client.phone}")
|
||||
print(f"5. Notes: {client.notes}")
|
||||
print("6. Tout modifier")
|
||||
print("7. Annuler")
|
||||
|
||||
# Choisir ce qu'il faut modifier
|
||||
edit_choice = input("\nQue souhaitez-vous modifier (1-7): ")
|
||||
|
||||
if edit_choice == "7":
|
||||
print("Modification annulée.")
|
||||
return
|
||||
|
||||
if edit_choice == "1" or edit_choice == "6":
|
||||
client.name = input(f"Nouveau nom [{client.name}]: ") or client.name
|
||||
|
||||
if edit_choice == "2" or edit_choice == "6":
|
||||
client.company = input(f"Nouvelle entreprise [{client.company}]: ") or client.company
|
||||
|
||||
if edit_choice == "3" or edit_choice == "6":
|
||||
client.email = input(f"Nouvel email [{client.email}]: ") or client.email
|
||||
|
||||
if edit_choice == "4" or edit_choice == "6":
|
||||
client.phone = input(f"Nouveau téléphone [{client.phone}]: ") or client.phone
|
||||
|
||||
if edit_choice == "5" or edit_choice == "6":
|
||||
client.notes = input(f"Nouvelles notes [{client.notes}]: ") or client.notes
|
||||
|
||||
# Mettre à jour le client
|
||||
client_handler.update_client(client)
|
||||
print(f"\nClient '{client.name}' mis à jour avec succès!")
|
||||
|
||||
def delete_client(client_handler):
|
||||
"""Supprime un client"""
|
||||
clear_screen()
|
||||
print("=== Suppression d'un Client ===\n")
|
||||
|
||||
# Afficher tous les clients pour sélection
|
||||
clients = client_handler.get_all_clients()
|
||||
|
||||
if not clients:
|
||||
print("Aucun client trouvé.")
|
||||
return
|
||||
|
||||
for i, client in enumerate(clients, 1):
|
||||
print(f"{i}. {client.name} ({client.company})")
|
||||
|
||||
# Sélectionner un client à supprimer
|
||||
choice = input("\nEntrez le numéro du client à supprimer (ou Entrée pour annuler): ")
|
||||
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
|
||||
print("Opération annulée ou sélection invalide.")
|
||||
return
|
||||
|
||||
client = clients[int(choice) - 1]
|
||||
|
||||
# Confirmer la suppression
|
||||
confirm = input(f"Êtes-vous sûr de vouloir supprimer '{client.name}'? (o/n): ")
|
||||
|
||||
if confirm.lower() != 'o':
|
||||
print("Suppression annulée.")
|
||||
return
|
||||
|
||||
# Supprimer le client
|
||||
client_handler.delete_client(client.id)
|
||||
print(f"\nClient '{client.name}' supprimé avec succès!")
|
||||
|
||||
def view_client_documents(client_handler):
|
||||
"""Affiche les documents associés à un client"""
|
||||
clear_screen()
|
||||
print("=== Documents d'un Client ===\n")
|
||||
|
||||
# Afficher tous les clients pour sélection
|
||||
clients = client_handler.get_all_clients()
|
||||
|
||||
if not clients:
|
||||
print("Aucun client trouvé.")
|
||||
return
|
||||
|
||||
for i, client in enumerate(clients, 1):
|
||||
print(f"{i}. {client.name} ({client.company})")
|
||||
|
||||
# Sélectionner un client
|
||||
choice = input("\nEntrez le numéro du client pour voir ses documents (ou Entrée pour annuler): ")
|
||||
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(clients):
|
||||
print("Opération annulée ou sélection invalide.")
|
||||
return
|
||||
|
||||
client = clients[int(choice) - 1]
|
||||
display_client_documents(client)
|
||||
|
||||
def display_client_details(client):
|
||||
"""Affiche les détails d'un client"""
|
||||
clear_screen()
|
||||
print(f"=== Détails du Client: {client.name} ===\n")
|
||||
|
||||
print(f"ID: {client.id}")
|
||||
print(f"Nom: {client.name}")
|
||||
print(f"Entreprise: {client.company}")
|
||||
print(f"Email: {client.email}")
|
||||
print(f"Téléphone: {client.phone}")
|
||||
print(f"Notes: {client.notes}")
|
||||
print(f"Dernière interaction: {client.last_contact}")
|
||||
print(f"Prochaine action: {client.next_action}")
|
||||
|
||||
if client.tags:
|
||||
print(f"Tags: {', '.join(client.tags)}")
|
||||
|
||||
# Afficher les documents
|
||||
display_client_documents(client)
|
||||
|
||||
def display_client_documents(client):
|
||||
"""Affiche les documents associés à un client"""
|
||||
print(f"\n=== Documents de {client.name} ===\n")
|
||||
|
||||
devis = client.linked_docs.get('devis', [])
|
||||
propositions = client.linked_docs.get('propositions', [])
|
||||
factures = client.linked_docs.get('factures', [])
|
||||
|
||||
if not devis and not propositions and not factures:
|
||||
print("Aucun document trouvé pour ce client.")
|
||||
return
|
||||
|
||||
if devis:
|
||||
print("Devis:")
|
||||
for i, doc in enumerate(devis, 1):
|
||||
print(f" {i}. {os.path.basename(doc)}")
|
||||
|
||||
if propositions:
|
||||
print("\nPropositions commerciales:")
|
||||
for i, doc in enumerate(propositions, 1):
|
||||
print(f" {i}. {os.path.basename(doc)}")
|
||||
|
||||
if factures:
|
||||
print("\nFactures:")
|
||||
for i, doc in enumerate(factures, 1):
|
||||
print(f" {i}. {os.path.basename(doc)}")
|
||||
|
||||
# Option pour ouvrir un document
|
||||
print("\n1. Ouvrir un document")
|
||||
print("2. Retour")
|
||||
|
||||
choice = input("\nQue souhaitez-vous faire (1-2): ")
|
||||
|
||||
if choice == "1":
|
||||
# Choisir le type de document
|
||||
print("\nType de document:")
|
||||
print("1. Devis")
|
||||
print("2. Proposition commerciale")
|
||||
print("3. Facture")
|
||||
|
||||
doc_type_choice = input("\nSélectionnez le type de document (1-3): ")
|
||||
|
||||
doc_list = []
|
||||
if doc_type_choice == "1" and devis:
|
||||
doc_list = devis
|
||||
doc_type_name = "devis"
|
||||
elif doc_type_choice == "2" and propositions:
|
||||
doc_list = propositions
|
||||
doc_type_name = "proposition commerciale"
|
||||
elif doc_type_choice == "3" and factures:
|
||||
doc_list = factures
|
||||
doc_type_name = "facture"
|
||||
else:
|
||||
print("Choix invalide ou aucun document de ce type.")
|
||||
return
|
||||
|
||||
# Afficher les documents du type sélectionné
|
||||
print(f"\n{doc_type_name.capitalize()}s disponibles:")
|
||||
for i, doc in enumerate(doc_list, 1):
|
||||
print(f" {i}. {os.path.basename(doc)}")
|
||||
|
||||
# Sélectionner un document à ouvrir
|
||||
doc_choice = input(f"\nSélectionnez un {doc_type_name} à ouvrir (1-{len(doc_list)}): ")
|
||||
|
||||
if doc_choice.isdigit() and 1 <= int(doc_choice) <= len(doc_list):
|
||||
selected_doc = doc_list[int(doc_choice) - 1]
|
||||
open_document(selected_doc)
|
||||
else:
|
||||
print("Sélection invalide.")
|
||||
|
||||
def open_document(document_path):
|
||||
"""Ouvre un document PDF"""
|
||||
try:
|
||||
if os.path.exists(document_path):
|
||||
if os.name == 'nt': # Windows
|
||||
os.startfile(document_path)
|
||||
elif os.name == 'posix': # macOS et Linux
|
||||
if 'darwin' in os.uname().sysname.lower(): # macOS
|
||||
os.system(f'open "{document_path}"')
|
||||
else: # Linux
|
||||
os.system(f'xdg-open "{document_path}"')
|
||||
print(f"Document ouvert: {os.path.basename(document_path)}")
|
||||
else:
|
||||
print(f"Erreur: Le document {document_path} n'existe pas.")
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de l'ouverture du document: {e}")
|
||||
|
||||
def manage_clients(client_handler, prospect_handler):
|
||||
"""Gestion des clients"""
|
||||
while True:
|
||||
clear_screen()
|
||||
print("=== Gestion des Clients ===\n")
|
||||
|
||||
print("1. Afficher tous les clients")
|
||||
print("2. Rechercher un client")
|
||||
print("3. Ajouter un nouveau client")
|
||||
print("4. Modifier un client")
|
||||
print("5. Supprimer un client")
|
||||
print("6. Voir les documents d'un client")
|
||||
print("7. Retour")
|
||||
|
||||
choice = input("\nSélectionnez une option (1-7): ")
|
||||
|
||||
if choice == "1":
|
||||
# Afficher tous les clients
|
||||
display_all_clients(client_handler)
|
||||
elif choice == "2":
|
||||
# Rechercher un client
|
||||
search_clients(client_handler)
|
||||
elif choice == "3":
|
||||
# Ajouter un nouveau client
|
||||
add_new_client(client_handler)
|
||||
elif choice == "4":
|
||||
# Modifier un client
|
||||
edit_client(client_handler)
|
||||
elif choice == "5":
|
||||
# Supprimer un client
|
||||
delete_client(client_handler)
|
||||
elif choice == "6":
|
||||
# Voir les documents d'un client
|
||||
view_client_documents(client_handler)
|
||||
elif choice == "7":
|
||||
# Retour
|
||||
break
|
||||
else:
|
||||
print("\nOption invalide. Veuillez réessayer.")
|
||||
|
||||
input("\nAppuyez sur Entrée pour continuer...")
|
||||
|
||||
def manage_prospects(client_handler, prospect_handler):
|
||||
"""Gestion des prospects"""
|
||||
while True:
|
||||
clear_screen()
|
||||
print("=== Gestion des Prospects ===\n")
|
||||
|
||||
print("1. Afficher tous les prospects")
|
||||
print("2. Rechercher un prospect")
|
||||
print("3. Ajouter un nouveau prospect")
|
||||
print("4. Modifier un prospect")
|
||||
print("5. Supprimer un prospect")
|
||||
print("6. Convertir un prospect en client")
|
||||
print("7. Afficher les prospects par statut")
|
||||
print("8. Retour")
|
||||
|
||||
choice = input("\nSélectionnez une option (1-8): ")
|
||||
|
||||
if choice == "1":
|
||||
# Afficher tous les prospects
|
||||
display_all_prospects(prospect_handler)
|
||||
elif choice == "2":
|
||||
# Rechercher un prospect
|
||||
search_prospects(prospect_handler)
|
||||
elif choice == "3":
|
||||
# Ajouter un nouveau prospect
|
||||
add_new_prospect(prospect_handler)
|
||||
elif choice == "4":
|
||||
# Modifier un prospect
|
||||
edit_prospect(prospect_handler)
|
||||
elif choice == "5":
|
||||
# Supprimer un prospect
|
||||
delete_prospect(prospect_handler)
|
||||
elif choice == "6":
|
||||
# Convertir un prospect en client
|
||||
convert_prospect_to_client(prospect_handler, client_handler)
|
||||
elif choice == "7":
|
||||
# Afficher les prospects par statut
|
||||
display_prospects_by_status(prospect_handler)
|
||||
elif choice == "8":
|
||||
# Retour
|
||||
break
|
||||
else:
|
||||
print("\nOption invalide. Veuillez réessayer.")
|
||||
|
||||
input("\nAppuyez sur Entrée pour continuer...")
|
||||
|
||||
def show_dashboard(client_handler, prospect_handler):
|
||||
"""Affiche un tableau de bord avec des statistiques"""
|
||||
clear_screen()
|
||||
print("=== Tableau de Bord CRM ===\n")
|
||||
|
||||
# Récupérer les données
|
||||
clients = client_handler.get_all_clients()
|
||||
prospects = prospect_handler.get_all_prospects()
|
||||
|
||||
# Statistiques de base
|
||||
print(f"Nombre total de clients: {len(clients)}")
|
||||
print(f"Nombre total de prospects: {len(prospects)}")
|
||||
|
||||
# Statistiques des prospects par statut
|
||||
statuses = {}
|
||||
for prospect in prospects:
|
||||
status = prospect.status
|
||||
if status in statuses:
|
||||
statuses[status] += 1
|
||||
else:
|
||||
statuses[status] = 1
|
||||
|
||||
if statuses:
|
||||
print("\nProspects par statut:")
|
||||
for status, count in statuses.items():
|
||||
print(f" {status}: {count}")
|
||||
|
||||
# Derniers prospects ajoutés
|
||||
recent_prospects = prospect_handler.get_recent_prospects(days=30)
|
||||
if recent_prospects:
|
||||
print(f"\nNouveaux prospects (30 derniers jours): {len(recent_prospects)}")
|
||||
|
||||
# Activité récente
|
||||
print("\nRécapitulatif des activités récentes:")
|
||||
print(" Propositions commerciales en attente...")
|
||||
print(" Devis en cours...")
|
||||
|
||||
# Suggestions d'actions
|
||||
print("\nSuggestions d'actions:")
|
||||
if len(prospects) > 0:
|
||||
print(" • Suivez vos prospects pour augmenter votre taux de conversion")
|
||||
if len(clients) > 0:
|
||||
print(" • Contactez vos clients existants pour des opportunités de vente additionnelle")
|
||||
if len(prospects) == 0:
|
||||
print(" • Ajoutez des prospects pour développer votre portefeuille client")
|
||||
|
||||
def display_all_prospects(prospect_handler):
|
||||
"""Affiche tous les prospects"""
|
||||
clear_screen()
|
||||
print("=== Liste des Prospects ===\n")
|
||||
|
||||
prospects = prospect_handler.get_all_prospects()
|
||||
|
||||
if not prospects:
|
||||
print("Aucun prospect trouvé.")
|
||||
return
|
||||
|
||||
for i, prospect in enumerate(prospects, 1):
|
||||
print(f"{i}. {prospect.name} ({prospect.company})")
|
||||
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
|
||||
print(f" Statut: {prospect.status} | Source: {prospect.source}")
|
||||
print(f" Dernière interaction: {prospect.last_contact}")
|
||||
print("")
|
||||
|
||||
def search_prospects(prospect_handler):
|
||||
"""Recherche des prospects par nom"""
|
||||
clear_screen()
|
||||
print("=== Recherche de Prospects ===\n")
|
||||
|
||||
search_term = input("Entrez un terme de recherche (nom, email, entreprise): ")
|
||||
|
||||
if not search_term:
|
||||
print("Recherche annulée.")
|
||||
return
|
||||
|
||||
results = []
|
||||
prospects = prospect_handler.get_all_prospects()
|
||||
|
||||
for prospect in prospects:
|
||||
if (search_term.lower() in prospect.name.lower() or
|
||||
search_term.lower() in prospect.email.lower() or
|
||||
search_term.lower() in prospect.company.lower()):
|
||||
results.append(prospect)
|
||||
|
||||
if not results:
|
||||
print(f"Aucun prospect trouvé pour '{search_term}'.")
|
||||
return
|
||||
|
||||
print(f"\n{len(results)} prospect(s) trouvé(s):\n")
|
||||
|
||||
for i, prospect in enumerate(results, 1):
|
||||
print(f"{i}. {prospect.name} ({prospect.company})")
|
||||
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
|
||||
print(f" Statut: {prospect.status}")
|
||||
print("")
|
||||
|
||||
# Offrir la possibilité de voir les détails d'un prospect
|
||||
if results:
|
||||
choice = input("\nEntrez le numéro du prospect pour voir les détails (ou Entrée pour annuler): ")
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(results):
|
||||
prospect = results[int(choice) - 1]
|
||||
display_prospect_details(prospect)
|
||||
|
||||
def add_new_prospect(prospect_handler):
|
||||
"""Ajoute un nouveau prospect"""
|
||||
clear_screen()
|
||||
print("=== Ajout d'un Nouveau Prospect ===\n")
|
||||
|
||||
# Recueillir les informations du prospect
|
||||
name = input("Nom du prospect: ")
|
||||
if not name:
|
||||
print("Le nom est obligatoire. Opération annulée.")
|
||||
return
|
||||
|
||||
company = input("Entreprise: ")
|
||||
email = input("Email: ")
|
||||
phone = input("Téléphone: ")
|
||||
source = input("Source (site web, référence, salon, etc.): ")
|
||||
notes = input("Notes: ")
|
||||
|
||||
# Statut du prospect
|
||||
print("\nStatut du prospect:")
|
||||
print("1. Nouveau")
|
||||
print("2. Contacté")
|
||||
print("3. Qualifié")
|
||||
print("4. Proposition envoyée")
|
||||
print("5. Non intéressé")
|
||||
|
||||
status_choice = input("\nSélectionnez le statut (1-5): ")
|
||||
status = "Nouveau" # Défaut
|
||||
|
||||
if status_choice == "1":
|
||||
status = "Nouveau"
|
||||
elif status_choice == "2":
|
||||
status = "Contacté"
|
||||
elif status_choice == "3":
|
||||
status = "Qualifié"
|
||||
elif status_choice == "4":
|
||||
status = "Proposition envoyée"
|
||||
elif status_choice == "5":
|
||||
status = "Non intéressé"
|
||||
|
||||
# Créer le nouveau prospect
|
||||
new_prospect = Prospect(
|
||||
name=name,
|
||||
company=company,
|
||||
email=email,
|
||||
phone=phone,
|
||||
source=source,
|
||||
notes=notes,
|
||||
status=status
|
||||
)
|
||||
|
||||
# Ajouter le prospect
|
||||
prospect_handler.add_prospect(new_prospect)
|
||||
print(f"\nProspect '{name}' ajouté avec succès!")
|
||||
|
||||
def edit_prospect(prospect_handler):
|
||||
"""Modifie un prospect existant"""
|
||||
clear_screen()
|
||||
print("=== Modification d'un Prospect ===\n")
|
||||
|
||||
# Afficher tous les prospects pour sélection
|
||||
prospects = prospect_handler.get_all_prospects()
|
||||
|
||||
if not prospects:
|
||||
print("Aucun prospect trouvé.")
|
||||
return
|
||||
|
||||
for i, prospect in enumerate(prospects, 1):
|
||||
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
|
||||
|
||||
# Sélectionner un prospect à modifier
|
||||
choice = input("\nEntrez le numéro du prospect à modifier (ou Entrée pour annuler): ")
|
||||
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
|
||||
print("Opération annulée ou sélection invalide.")
|
||||
return
|
||||
|
||||
prospect = prospects[int(choice) - 1]
|
||||
|
||||
# Afficher les détails actuels
|
||||
print(f"\nModification de '{prospect.name}':")
|
||||
print(f"1. Nom: {prospect.name}")
|
||||
print(f"2. Entreprise: {prospect.company}")
|
||||
print(f"3. Email: {prospect.email}")
|
||||
print(f"4. Téléphone: {prospect.phone}")
|
||||
print(f"5. Source: {prospect.source}")
|
||||
print(f"6. Statut: {prospect.status}")
|
||||
print(f"7. Notes: {prospect.notes}")
|
||||
print("8. Tout modifier")
|
||||
print("9. Annuler")
|
||||
|
||||
# Choisir ce qu'il faut modifier
|
||||
edit_choice = input("\nQue souhaitez-vous modifier (1-9): ")
|
||||
|
||||
if edit_choice == "9":
|
||||
print("Modification annulée.")
|
||||
return
|
||||
|
||||
if edit_choice == "1" or edit_choice == "8":
|
||||
prospect.name = input(f"Nouveau nom [{prospect.name}]: ") or prospect.name
|
||||
|
||||
if edit_choice == "2" or edit_choice == "8":
|
||||
prospect.company = input(f"Nouvelle entreprise [{prospect.company}]: ") or prospect.company
|
||||
|
||||
if edit_choice == "3" or edit_choice == "8":
|
||||
prospect.email = input(f"Nouvel email [{prospect.email}]: ") or prospect.email
|
||||
|
||||
if edit_choice == "4" or edit_choice == "8":
|
||||
prospect.phone = input(f"Nouveau téléphone [{prospect.phone}]: ") or prospect.phone
|
||||
|
||||
if edit_choice == "5" or edit_choice == "8":
|
||||
prospect.source = input(f"Nouvelle source [{prospect.source}]: ") or prospect.source
|
||||
|
||||
if edit_choice == "6" or edit_choice == "8":
|
||||
print("\nStatut du prospect:")
|
||||
print("1. Nouveau")
|
||||
print("2. Contacté")
|
||||
print("3. Qualifié")
|
||||
print("4. Proposition envoyée")
|
||||
print("5. Non intéressé")
|
||||
|
||||
status_choice = input(f"\nSélectionnez le statut (1-5) [Actuel: {prospect.status}]: ")
|
||||
|
||||
if status_choice == "1":
|
||||
prospect.status = "Nouveau"
|
||||
elif status_choice == "2":
|
||||
prospect.status = "Contacté"
|
||||
elif status_choice == "3":
|
||||
prospect.status = "Qualifié"
|
||||
elif status_choice == "4":
|
||||
prospect.status = "Proposition envoyée"
|
||||
elif status_choice == "5":
|
||||
prospect.status = "Non intéressé"
|
||||
|
||||
if edit_choice == "7" or edit_choice == "8":
|
||||
prospect.notes = input(f"Nouvelles notes [{prospect.notes}]: ") or prospect.notes
|
||||
|
||||
# Mettre à jour le prospect
|
||||
prospect_handler.update_prospect(prospect)
|
||||
print(f"\nProspect '{prospect.name}' mis à jour avec succès!")
|
||||
|
||||
def delete_prospect(prospect_handler):
|
||||
"""Supprime un prospect"""
|
||||
clear_screen()
|
||||
print("=== Suppression d'un Prospect ===\n")
|
||||
|
||||
# Afficher tous les prospects pour sélection
|
||||
prospects = prospect_handler.get_all_prospects()
|
||||
|
||||
if not prospects:
|
||||
print("Aucun prospect trouvé.")
|
||||
return
|
||||
|
||||
for i, prospect in enumerate(prospects, 1):
|
||||
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
|
||||
|
||||
# Sélectionner un prospect à supprimer
|
||||
choice = input("\nEntrez le numéro du prospect à supprimer (ou Entrée pour annuler): ")
|
||||
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
|
||||
print("Opération annulée ou sélection invalide.")
|
||||
return
|
||||
|
||||
prospect = prospects[int(choice) - 1]
|
||||
|
||||
# Confirmer la suppression
|
||||
confirm = input(f"Êtes-vous sûr de vouloir supprimer '{prospect.name}'? (o/n): ")
|
||||
|
||||
if confirm.lower() != 'o':
|
||||
print("Suppression annulée.")
|
||||
return
|
||||
|
||||
# Supprimer le prospect
|
||||
prospect_handler.delete_prospect(prospect.id)
|
||||
print(f"\nProspect '{prospect.name}' supprimé avec succès!")
|
||||
|
||||
def convert_prospect_to_client(prospect_handler, client_handler):
|
||||
"""Convertit un prospect en client"""
|
||||
clear_screen()
|
||||
print("=== Conversion d'un Prospect en Client ===\n")
|
||||
|
||||
# Afficher tous les prospects pour sélection
|
||||
prospects = prospect_handler.get_all_prospects()
|
||||
|
||||
if not prospects:
|
||||
print("Aucun prospect trouvé.")
|
||||
return
|
||||
|
||||
for i, prospect in enumerate(prospects, 1):
|
||||
print(f"{i}. {prospect.name} ({prospect.company}) - {prospect.status}")
|
||||
|
||||
# Sélectionner un prospect à convertir
|
||||
choice = input("\nEntrez le numéro du prospect à convertir en client (ou Entrée pour annuler): ")
|
||||
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(prospects):
|
||||
print("Opération annulée ou sélection invalide.")
|
||||
return
|
||||
|
||||
prospect = prospects[int(choice) - 1]
|
||||
|
||||
# Confirmer la conversion
|
||||
confirm = input(f"Êtes-vous sûr de vouloir convertir '{prospect.name}' en client? (o/n): ")
|
||||
|
||||
if confirm.lower() != 'o':
|
||||
print("Conversion annulée.")
|
||||
return
|
||||
|
||||
# Convertir le prospect en client
|
||||
client = prospect_handler.convert_to_client(prospect.id, client_handler)
|
||||
if client:
|
||||
print(f"\nProspect '{prospect.name}' converti en client avec succès!")
|
||||
else:
|
||||
print("\nErreur lors de la conversion du prospect en client.")
|
||||
|
||||
def display_prospect_details(prospect):
|
||||
"""Affiche les détails d'un prospect"""
|
||||
clear_screen()
|
||||
print(f"=== Détails du Prospect: {prospect.name} ===\n")
|
||||
|
||||
print(f"ID: {prospect.id}")
|
||||
print(f"Nom: {prospect.name}")
|
||||
print(f"Entreprise: {prospect.company}")
|
||||
print(f"Email: {prospect.email}")
|
||||
print(f"Téléphone: {prospect.phone}")
|
||||
print(f"Source: {prospect.source}")
|
||||
print(f"Statut: {prospect.status}")
|
||||
print(f"Notes: {prospect.notes}")
|
||||
print(f"Dernière interaction: {prospect.last_contact}")
|
||||
print(f"Prochaine action: {prospect.next_action}")
|
||||
|
||||
if prospect.tags:
|
||||
print(f"Tags: {', '.join(prospect.tags)}")
|
||||
|
||||
def display_prospects_by_status(prospect_handler):
|
||||
"""Affiche les prospects regroupés par statut"""
|
||||
clear_screen()
|
||||
print("=== Prospects par Statut ===\n")
|
||||
|
||||
# Récupérer tous les prospects
|
||||
prospects = prospect_handler.get_all_prospects()
|
||||
|
||||
if not prospects:
|
||||
print("Aucun prospect trouvé.")
|
||||
return
|
||||
|
||||
# Regrouper les prospects par statut
|
||||
status_groups = {}
|
||||
for prospect in prospects:
|
||||
status = prospect.status
|
||||
if status not in status_groups:
|
||||
status_groups[status] = []
|
||||
status_groups[status].append(prospect)
|
||||
|
||||
# Afficher les prospects par groupe de statut
|
||||
for status, prospects_list in status_groups.items():
|
||||
print(f"\n=== {status} ({len(prospects_list)}) ===")
|
||||
for i, prospect in enumerate(prospects_list, 1):
|
||||
print(f"{i}. {prospect.name} ({prospect.company})")
|
||||
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
|
||||
|
||||
print("\n")
|
||||
|
||||
# Option pour filtrer par un statut spécifique
|
||||
print("Voulez-vous voir les détails d'un statut spécifique?")
|
||||
print("1. Nouveau")
|
||||
print("2. Contacté")
|
||||
print("3. Qualifié")
|
||||
print("4. Proposition envoyée")
|
||||
print("5. Non intéressé")
|
||||
print("6. Retour")
|
||||
|
||||
choice = input("\nSélectionnez une option (1-6): ")
|
||||
|
||||
status_mapping = {
|
||||
"1": "Nouveau",
|
||||
"2": "Contacté",
|
||||
"3": "Qualifié",
|
||||
"4": "Proposition envoyée",
|
||||
"5": "Non intéressé"
|
||||
}
|
||||
|
||||
if choice in status_mapping:
|
||||
selected_status = status_mapping[choice]
|
||||
filtered_prospects = prospect_handler.get_prospects_by_status(selected_status)
|
||||
|
||||
if filtered_prospects:
|
||||
clear_screen()
|
||||
print(f"=== Prospects avec statut: {selected_status} ===\n")
|
||||
|
||||
for i, prospect in enumerate(filtered_prospects, 1):
|
||||
print(f"{i}. {prospect.name} ({prospect.company})")
|
||||
print(f" Email: {prospect.email} | Téléphone: {prospect.phone}")
|
||||
print(f" Dernière interaction: {prospect.last_contact}")
|
||||
print(f" Notes: {prospect.notes[:50]}..." if len(prospect.notes) > 50 else f" Notes: {prospect.notes}")
|
||||
print("")
|
||||
|
||||
# Option pour voir les détails d'un prospect spécifique
|
||||
detail_choice = input("\nEntrez le numéro du prospect pour voir les détails (ou Entrée pour annuler): ")
|
||||
if detail_choice.isdigit() and 1 <= int(detail_choice) <= len(filtered_prospects):
|
||||
prospect = filtered_prospects[int(detail_choice) - 1]
|
||||
display_prospect_details(prospect)
|
||||
else:
|
||||
print(f"\nAucun prospect avec le statut '{selected_status}' trouvé.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
modules/crm/client.py
Normal file
54
modules/crm/client.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from datetime import date
|
||||
import uuid
|
||||
|
||||
from datetime import date
|
||||
import uuid
|
||||
|
||||
class Client:
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
company: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
notes: str = "",
|
||||
tags: list = None,
|
||||
last_contact: str = None,
|
||||
next_action: str = "",
|
||||
linked_docs: dict = None,
|
||||
id: str = None,
|
||||
created_at: str = None
|
||||
):
|
||||
self.id = id or f"cli_{uuid.uuid4().hex[:8]}"
|
||||
self.name = name
|
||||
self.company = company
|
||||
self.email = email
|
||||
self.phone = phone
|
||||
self.notes = notes
|
||||
self.tags = tags or []
|
||||
self.last_contact = last_contact or str(date.today())
|
||||
self.next_action = next_action
|
||||
self.linked_docs = linked_docs or {"devis": [], "propositions": [], "factures": []}
|
||||
self.created_at = created_at or str(date.today())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"company": self.company,
|
||||
"email": self.email,
|
||||
"phone": self.phone,
|
||||
"notes": self.notes,
|
||||
"tags": self.tags,
|
||||
"last_contact": self.last_contact,
|
||||
"next_action": self.next_action,
|
||||
"linked_docs": self.linked_docs,
|
||||
"created_at": self.created_at
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict):
|
||||
return Client(**data)
|
||||
|
||||
def __str__(self):
|
||||
return f"Client({self.name}, {self.company}, {self.email}, {self.phone})"
|
||||
172
modules/crm/handler.py
Normal file
172
modules/crm/handler.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
from datetime import date
|
||||
import os
|
||||
import json
|
||||
from .client import Client
|
||||
from core.data import Data
|
||||
|
||||
class ClientHandler:
|
||||
def __init__(self, clients_folder="Data/clients"):
|
||||
self.clients_folder = clients_folder
|
||||
self.clients = []
|
||||
self.load_clients()
|
||||
|
||||
def load_clients(self):
|
||||
"""Charge tous les clients depuis les fichiers existants"""
|
||||
self.clients = []
|
||||
# Charger les clients depuis les fichiers JSON existants
|
||||
if os.path.exists(self.clients_folder):
|
||||
for filename in os.listdir(self.clients_folder):
|
||||
if filename.endswith('.json'):
|
||||
client_path = os.path.join(self.clients_folder, filename)
|
||||
try:
|
||||
# Utiliser le gestionnaire de données pour charger le fichier JSON
|
||||
data_manager = Data(client_path)
|
||||
client_data = data_manager.load_data()
|
||||
|
||||
# Si le fichier a la structure d'un client, créer un objet Client
|
||||
if "client_name" in client_data:
|
||||
# Conversion du format existant vers le format Client
|
||||
client_obj = {
|
||||
"name": client_data.get("client_name", ""),
|
||||
"email": client_data.get("client_email", ""),
|
||||
"phone": client_data.get("client_phone", ""),
|
||||
"notes": client_data.get("additional_info", ""),
|
||||
"company": client_data.get("project_name", ""),
|
||||
"tags": [], # Pas de tags dans les données existantes
|
||||
"next_action": "", # Pas d'action suivante dans les données existantes
|
||||
"linked_docs": self._find_linked_docs(client_data.get("client_name", ""))
|
||||
}
|
||||
client = Client(**client_obj)
|
||||
self.clients.append(client)
|
||||
except Exception as e:
|
||||
print(f"Erreur lors du chargement du client {filename}: {e}")
|
||||
|
||||
return self.clients
|
||||
|
||||
def _find_linked_docs(self, client_name):
|
||||
"""Trouve les documents associés à un client"""
|
||||
linked_docs = {"devis": [], "propositions": [], "factures": []}
|
||||
client_name_normalized = client_name.replace(" ", "_").lower()
|
||||
|
||||
# Rechercher les devis
|
||||
devis_folder = "output/devis"
|
||||
if os.path.exists(devis_folder):
|
||||
for filename in os.listdir(devis_folder):
|
||||
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
|
||||
linked_docs["devis"].append(os.path.join(devis_folder, filename))
|
||||
|
||||
# Rechercher les propositions
|
||||
propositions_folder = "output/propositions"
|
||||
if os.path.exists(propositions_folder):
|
||||
for filename in os.listdir(propositions_folder):
|
||||
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
|
||||
linked_docs["propositions"].append(os.path.join(propositions_folder, filename))
|
||||
|
||||
# Rechercher les factures (si elles existent)
|
||||
factures_folder = "output/factures"
|
||||
if os.path.exists(factures_folder):
|
||||
for filename in os.listdir(factures_folder):
|
||||
if filename.startswith(client_name_normalized) and filename.endswith(".pdf"):
|
||||
linked_docs["factures"].append(os.path.join(factures_folder, filename))
|
||||
|
||||
return linked_docs
|
||||
|
||||
def get_all_clients(self):
|
||||
"""Retourne tous les clients"""
|
||||
return self.clients
|
||||
|
||||
def get_client_by_id(self, client_id):
|
||||
"""Retourne un client par son ID"""
|
||||
for client in self.clients:
|
||||
if client.id == client_id:
|
||||
return client
|
||||
return None
|
||||
|
||||
def get_client_by_name(self, client_name):
|
||||
"""Retourne un client par son nom"""
|
||||
for client in self.clients:
|
||||
if client.name.lower() == client_name.lower():
|
||||
return client
|
||||
return None
|
||||
|
||||
def add_client(self, client):
|
||||
"""Ajoute un nouveau client"""
|
||||
self.clients.append(client)
|
||||
# Sauvegarder le client dans un fichier JSON
|
||||
self._save_client(client)
|
||||
return client
|
||||
|
||||
def update_client(self, client):
|
||||
"""Met à jour un client existant"""
|
||||
for i, existing_client in enumerate(self.clients):
|
||||
if existing_client.id == client.id:
|
||||
self.clients[i] = client
|
||||
# Sauvegarder le client mis à jour
|
||||
self._save_client(client)
|
||||
return client
|
||||
return None
|
||||
|
||||
def delete_client(self, client_id):
|
||||
"""Supprime un client"""
|
||||
for i, client in enumerate(self.clients):
|
||||
if client.id == client_id:
|
||||
deleted_client = self.clients.pop(i)
|
||||
# Supprimer le fichier JSON du client
|
||||
client_file = os.path.join(self.clients_folder, f"{deleted_client.name.replace(' ', '_').lower()}.json")
|
||||
if os.path.exists(client_file):
|
||||
os.remove(client_file)
|
||||
return deleted_client
|
||||
return None
|
||||
|
||||
def _save_client(self, client):
|
||||
"""Sauvegarde un client dans un fichier JSON"""
|
||||
client_data = {
|
||||
"client_name": client.name,
|
||||
"client_email": client.email,
|
||||
"client_phone": client.phone,
|
||||
"additional_info": client.notes,
|
||||
"project_name": client.company,
|
||||
# Autres champs pour maintenir la compatibilité avec le format existant
|
||||
"project_type": "",
|
||||
"project_description": "",
|
||||
"features": [],
|
||||
"budget": "",
|
||||
"payment_terms": "",
|
||||
"contact_info": f"{client.name}, {client.phone}",
|
||||
"deadline": ""
|
||||
}
|
||||
|
||||
client_file = os.path.join(self.clients_folder, f"{client.name.replace(' ', '_').lower()}.json")
|
||||
data_manager = Data(client_file)
|
||||
data_manager.save_data(client_data)
|
||||
|
||||
return client_file
|
||||
|
||||
def add_task_for_client(self, client_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
|
||||
"""
|
||||
Crée une tâche liée à un client.
|
||||
:param client_id: ID du client
|
||||
:param title: Titre de la tâche
|
||||
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
|
||||
:param description: Description optionnelle
|
||||
:param priority: 'basse' | 'normale' | 'haute'
|
||||
:param metadata: métadonnées optionnelles
|
||||
:return: ID de la tâche créée ou None si client introuvable
|
||||
"""
|
||||
client = self.get_client_by_id(client_id)
|
||||
if not client:
|
||||
return None
|
||||
# Import local pour éviter dépendances circulaires au chargement
|
||||
from modules.tasks.task import Task
|
||||
from modules.tasks.task_handler import TaskHandler
|
||||
|
||||
task = Task(
|
||||
title=title,
|
||||
due_date=due_date,
|
||||
description=description,
|
||||
entity_type="client",
|
||||
entity_id=client_id,
|
||||
priority=priority,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return TaskHandler().add_task(task)
|
||||
95
modules/crm/prospect.py
Normal file
95
modules/crm/prospect.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from datetime import date
|
||||
import uuid
|
||||
from typing import Union
|
||||
|
||||
from .status import ProspectStatus
|
||||
|
||||
|
||||
class Prospect:
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
company: str = "",
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
source: str = "", # D'où vient ce prospect (site web, référence, etc.)
|
||||
notes: str = "",
|
||||
status: Union[ProspectStatus, str] = ProspectStatus.NOUVEAU, # Nouveau, Contacté, Qualifié, Proposition, Non intéressé
|
||||
tags: list = None,
|
||||
last_contact: str = None,
|
||||
next_action: str = "",
|
||||
linked_docs: dict = None,
|
||||
id: str = None,
|
||||
created_at: str = None,
|
||||
score: int = 0,
|
||||
last_interaction: str = None
|
||||
):
|
||||
self.id = id or f"pros_{uuid.uuid4().hex[:8]}"
|
||||
self.name = name
|
||||
self.company = company
|
||||
self.email = email
|
||||
self.phone = phone
|
||||
self.source = source
|
||||
self.notes = notes
|
||||
# Stockage interne en Enum
|
||||
self._status: ProspectStatus = ProspectStatus.from_value(status)
|
||||
self.tags = tags or []
|
||||
self.last_contact = last_contact or str(date.today())
|
||||
self.next_action = next_action
|
||||
self.linked_docs = linked_docs or {"propositions": []}
|
||||
self.created_at = created_at or str(date.today())
|
||||
self.score = score or 0
|
||||
self.last_interaction = last_interaction
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Exposition publique du statut en chaîne pour compatibilité (templates, JSON)."""
|
||||
return self._status.value
|
||||
|
||||
@status.setter
|
||||
def status(self, value: Union[ProspectStatus, str]) -> None:
|
||||
"""Permet d'assigner soit une chaîne, soit l'enum directement."""
|
||||
self._status = ProspectStatus.from_value(value)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"company": self.company,
|
||||
"email": self.email,
|
||||
"phone": self.phone,
|
||||
"source": self.source,
|
||||
"notes": self.notes,
|
||||
"status": self.status, # sérialise en chaîne
|
||||
"tags": self.tags,
|
||||
"last_contact": self.last_contact,
|
||||
"next_action": self.next_action,
|
||||
"linked_docs": self.linked_docs,
|
||||
"created_at": self.created_at,
|
||||
"score": self.score,
|
||||
"last_interaction": self.last_interaction
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict):
|
||||
# __init__ gère la conversion status -> Enum
|
||||
return Prospect(**data)
|
||||
|
||||
def convert_to_client(self):
|
||||
"""Convertit ce prospect en client"""
|
||||
from .client import Client
|
||||
|
||||
return Client(
|
||||
name=self.name,
|
||||
company=self.company,
|
||||
email=self.email,
|
||||
phone=self.phone,
|
||||
notes=self.notes,
|
||||
tags=self.tags,
|
||||
last_contact=self.last_contact,
|
||||
next_action=self.next_action,
|
||||
linked_docs=self.linked_docs
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Prospect({self.name}, {self.company}, {self.status}, {self.email})"
|
||||
157
modules/crm/prospect_handler.py
Normal file
157
modules/crm/prospect_handler.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
from datetime import date
|
||||
import os
|
||||
import json
|
||||
from .prospect import Prospect
|
||||
from core.data import Data
|
||||
|
||||
class ProspectHandler:
|
||||
def __init__(self, prospects_folder="Data/prospects"):
|
||||
self.prospects_folder = prospects_folder
|
||||
self.prospects = []
|
||||
|
||||
# Créer le dossier des prospects s'il n'existe pas
|
||||
if not os.path.exists(self.prospects_folder):
|
||||
os.makedirs(self.prospects_folder)
|
||||
|
||||
self.load_prospects()
|
||||
|
||||
def load_prospects(self):
|
||||
"""Charge tous les prospects depuis les fichiers existants"""
|
||||
self.prospects = []
|
||||
# Charger les prospects depuis les fichiers JSON existants
|
||||
if os.path.exists(self.prospects_folder):
|
||||
for filename in os.listdir(self.prospects_folder):
|
||||
if filename.endswith('.json'):
|
||||
prospect_path = os.path.join(self.prospects_folder, filename)
|
||||
try:
|
||||
# Utiliser le gestionnaire de données pour charger le fichier JSON
|
||||
data_manager = Data(prospect_path)
|
||||
prospect_data = data_manager.load_data()
|
||||
|
||||
# Si le fichier a la structure d'un prospect, créer un objet Prospect
|
||||
if "name" in prospect_data:
|
||||
prospect = Prospect.from_dict(prospect_data)
|
||||
self.prospects.append(prospect)
|
||||
except Exception as e:
|
||||
print(f"Erreur lors du chargement du prospect {filename}: {e}")
|
||||
|
||||
return self.prospects
|
||||
|
||||
def get_all_prospects(self):
|
||||
"""Retourne tous les prospects"""
|
||||
return self.prospects
|
||||
|
||||
def get_prospect_by_id(self, prospect_id):
|
||||
"""Retourne un prospect par son ID"""
|
||||
for prospect in self.prospects:
|
||||
if prospect.id == prospect_id:
|
||||
return prospect
|
||||
return None
|
||||
|
||||
def get_prospect_by_name(self, prospect_name):
|
||||
"""Retourne un prospect par son nom"""
|
||||
for prospect in self.prospects:
|
||||
if prospect.name.lower() == prospect_name.lower():
|
||||
return prospect
|
||||
return None
|
||||
|
||||
def add_prospect(self, prospect):
|
||||
"""Ajoute un nouveau prospect"""
|
||||
self.prospects.append(prospect)
|
||||
# Sauvegarder le prospect dans un fichier JSON
|
||||
self._save_prospect(prospect)
|
||||
return prospect
|
||||
|
||||
def update_prospect(self, prospect):
|
||||
"""Met à jour un prospect existant"""
|
||||
for i, existing_prospect in enumerate(self.prospects):
|
||||
if existing_prospect.id == prospect.id:
|
||||
self.prospects[i] = prospect
|
||||
# Sauvegarder le prospect mis à jour
|
||||
self._save_prospect(prospect)
|
||||
return prospect
|
||||
return None
|
||||
|
||||
def delete_prospect(self, prospect_id):
|
||||
"""Supprime un prospect"""
|
||||
for i, prospect in enumerate(self.prospects):
|
||||
if prospect.id == prospect_id:
|
||||
deleted_prospect = self.prospects.pop(i)
|
||||
# Supprimer le fichier JSON du prospect
|
||||
prospect_file = os.path.join(self.prospects_folder, f"{deleted_prospect.id}.json")
|
||||
if os.path.exists(prospect_file):
|
||||
os.remove(prospect_file)
|
||||
return deleted_prospect
|
||||
return None
|
||||
|
||||
def convert_to_client(self, prospect_id, client_handler):
|
||||
"""Convertit un prospect en client et le supprime de la liste des prospects"""
|
||||
prospect = self.get_prospect_by_id(prospect_id)
|
||||
if prospect:
|
||||
# Créer un client à partir du prospect
|
||||
client = prospect.convert_to_client()
|
||||
# Ajouter le client
|
||||
client_handler.add_client(client)
|
||||
# Supprimer le prospect
|
||||
self.delete_prospect(prospect_id)
|
||||
return client
|
||||
return None
|
||||
|
||||
def _save_prospect(self, prospect):
|
||||
"""Sauvegarde un prospect dans un fichier JSON"""
|
||||
prospect_data = prospect.to_dict()
|
||||
|
||||
prospect_file = os.path.join(self.prospects_folder, f"{prospect.id}.json")
|
||||
data_manager = Data(prospect_file)
|
||||
data_manager.save_data(prospect_data)
|
||||
|
||||
return prospect_file
|
||||
|
||||
def get_prospects_by_status(self, status):
|
||||
"""Retourne les prospects par statut"""
|
||||
return [p for p in self.prospects if p.status.lower() == status.lower()]
|
||||
|
||||
def get_recent_prospects(self, days=30):
|
||||
"""Retourne les prospects créés dans les X derniers jours"""
|
||||
today = date.today()
|
||||
recent_prospects = []
|
||||
|
||||
for prospect in self.prospects:
|
||||
try:
|
||||
created_date = date.fromisoformat(prospect.created_at)
|
||||
delta = today - created_date
|
||||
if delta.days <= days:
|
||||
recent_prospects.append(prospect)
|
||||
except ValueError:
|
||||
# Si la date n'est pas au format ISO, ignorer ce prospect
|
||||
pass
|
||||
|
||||
return recent_prospects
|
||||
|
||||
def add_task_for_prospect(self, prospect_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
|
||||
"""
|
||||
Crée une tâche liée à un prospect.
|
||||
:param prospect_id: ID du prospect
|
||||
:param title: Titre de la tâche
|
||||
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
|
||||
:param description: Description optionnelle
|
||||
:param priority: 'basse' | 'normale' | 'haute'
|
||||
:param metadata: métadonnées optionnelles
|
||||
:return: ID de la tâche créée ou None si prospect introuvable
|
||||
"""
|
||||
prospect = self.get_prospect_by_id(prospect_id)
|
||||
if not prospect:
|
||||
return None
|
||||
from modules.tasks.task import Task
|
||||
from modules.tasks.task_handler import TaskHandler
|
||||
|
||||
task = Task(
|
||||
title=title,
|
||||
due_date=due_date,
|
||||
description=description,
|
||||
entity_type="prospect",
|
||||
entity_id=prospect_id,
|
||||
priority=priority,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return TaskHandler().add_task(task)
|
||||
52
modules/crm/scoring.py
Normal file
52
modules/crm/scoring.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from modules.crm.prospect_handler import ProspectHandler
|
||||
|
||||
|
||||
def categorize(score: int) -> Literal["Froid", "Tiède", "Chaud"]:
|
||||
if score is None:
|
||||
score = 0
|
||||
if score >= 50:
|
||||
return "Chaud"
|
||||
if score >= 20:
|
||||
return "Tiède"
|
||||
return "Froid"
|
||||
|
||||
|
||||
def adjust_score(prospect_id: str, delta: int) -> int:
|
||||
"""
|
||||
Ajuste le score d'un prospect et met à jour la dernière interaction.
|
||||
Retourne le score à jour.
|
||||
"""
|
||||
handler = ProspectHandler()
|
||||
p = handler.get_prospect_by_id(prospect_id)
|
||||
if not p:
|
||||
return 0
|
||||
try:
|
||||
current = int(getattr(p, "score", 0) or 0)
|
||||
except Exception:
|
||||
current = 0
|
||||
new_score = max(0, current + int(delta))
|
||||
setattr(p, "score", new_score)
|
||||
setattr(p, "last_interaction", date.today().isoformat())
|
||||
# On ne change pas le statut ici, sauf règle spécifique (reply)
|
||||
handler.update_prospect(p)
|
||||
return new_score
|
||||
|
||||
|
||||
def adjust_score_for_event(prospect_id: str, event: Literal["open", "click", "reply"]) -> int:
|
||||
if event == "open":
|
||||
return adjust_score(prospect_id, 10)
|
||||
if event == "click":
|
||||
return adjust_score(prospect_id, 20)
|
||||
if event == "reply":
|
||||
score = adjust_score(prospect_id, 30)
|
||||
# Règle: un reply passe le prospect en statut "Chaud"
|
||||
handler = ProspectHandler()
|
||||
p = handler.get_prospect_by_id(prospect_id)
|
||||
if p:
|
||||
p.status = "Chaud"
|
||||
handler.update_prospect(p)
|
||||
return score
|
||||
return adjust_score(prospect_id, 0)
|
||||
181
modules/crm/search.py
Normal file
181
modules/crm/search.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
# modules/crm/search.py -> remonter à la racine projet
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _prospects_dir() -> str:
|
||||
return os.path.join(_project_root(), "Data", "prospects")
|
||||
|
||||
|
||||
def _read_json(path: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _norm_date(date_str: Optional[str]) -> Optional[str]:
|
||||
if not date_str:
|
||||
return None
|
||||
# Tente de normaliser vers YYYY-MM-DD
|
||||
s = str(date_str)
|
||||
# Si déjà au format ISO ou contient "T"
|
||||
if len(s) >= 10:
|
||||
return s[:10]
|
||||
return None
|
||||
|
||||
|
||||
def _extract_fields(data: Dict[str, Any], fallback_id: str) -> Dict[str, Any]:
|
||||
# Essaye plusieurs variantes de clés
|
||||
name = data.get("name") or data.get("nom") or data.get("full_name") or data.get("contact_name") or ""
|
||||
email = data.get("email") or data.get("mail") or ""
|
||||
company = data.get("company") or data.get("societe") or data.get("entreprise") or data.get("organization") or ""
|
||||
status = data.get("status") or data.get("statut") or ""
|
||||
city = data.get("city") or data.get("ville") or ""
|
||||
tags = data.get("tags") or []
|
||||
if isinstance(tags, str):
|
||||
# transformer "a, b, c" -> ["a","b","c"]
|
||||
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
created_at = data.get("created_at") or data.get("created") or data.get("date_created") or ""
|
||||
created_at = _norm_date(created_at) or ""
|
||||
pid = data.get("id") or fallback_id
|
||||
|
||||
# Liaisons potentielles
|
||||
client_id = data.get("client_id") or data.get("clientId") or data.get("client")
|
||||
project_ids = data.get("project_ids") or data.get("projects") or data.get("projectId") or []
|
||||
if isinstance(project_ids, (str, int)):
|
||||
project_ids = [str(project_ids)]
|
||||
elif isinstance(project_ids, list):
|
||||
project_ids = [str(x) for x in project_ids]
|
||||
|
||||
return {
|
||||
"id": pid,
|
||||
"entity_type": "prospect",
|
||||
"name": name,
|
||||
"email": email,
|
||||
"company": company,
|
||||
"status": status,
|
||||
"city": city,
|
||||
"tags": tags,
|
||||
"created_at": created_at,
|
||||
"client_id": client_id,
|
||||
"project_ids": project_ids,
|
||||
}
|
||||
|
||||
|
||||
def load_all_prospects() -> List[Dict[str, Any]]:
|
||||
base_dir = _prospects_dir()
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
results: List[Dict[str, Any]] = []
|
||||
for fn in os.listdir(base_dir):
|
||||
if not fn.endswith(".json"):
|
||||
continue
|
||||
path = os.path.join(base_dir, fn)
|
||||
data = _read_json(path)
|
||||
if not data:
|
||||
continue
|
||||
pid = os.path.splitext(fn)[0]
|
||||
results.append(_extract_fields(data, pid))
|
||||
return results
|
||||
|
||||
|
||||
def _contains(hay: str, needle: str) -> bool:
|
||||
return needle in hay
|
||||
|
||||
|
||||
def _safe_lower(s: Optional[str]) -> str:
|
||||
return (s or "").lower()
|
||||
|
||||
|
||||
def filter_prospects(
|
||||
*,
|
||||
q: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
company: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
date_from: Optional[str] = None, # YYYY-MM-DD
|
||||
date_to: Optional[str] = None, # YYYY-MM-DD
|
||||
) -> List[Dict[str, Any]]:
|
||||
items = load_all_prospects()
|
||||
|
||||
q = _safe_lower(q)
|
||||
name = _safe_lower(name)
|
||||
email = _safe_lower(email)
|
||||
company = _safe_lower(company)
|
||||
status = _safe_lower(status)
|
||||
city = _safe_lower(city)
|
||||
tags = [t.strip().lower() for t in (tags or []) if t.strip()]
|
||||
|
||||
df = None
|
||||
dt = None
|
||||
try:
|
||||
if date_from:
|
||||
df = datetime.strptime(date_from, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
df = None
|
||||
try:
|
||||
if date_to:
|
||||
dt = datetime.strptime(date_to, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
dt = None
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
iname = _safe_lower(it.get("name"))
|
||||
iemail = _safe_lower(it.get("email"))
|
||||
icompany = _safe_lower(it.get("company"))
|
||||
istatus = _safe_lower(it.get("status"))
|
||||
icity = _safe_lower(it.get("city"))
|
||||
itags = [str(t).lower() for t in (it.get("tags") or [])]
|
||||
icreated = it.get("created_at") or ""
|
||||
icreated_d = None
|
||||
try:
|
||||
if icreated:
|
||||
icreated_d = datetime.strptime(icreated[:10], "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
icreated_d = None
|
||||
|
||||
# Filtre global q
|
||||
if q and not (
|
||||
_contains(iname, q) or _contains(iemail, q) or _contains(icompany, q) or _contains(icity, q)
|
||||
):
|
||||
continue
|
||||
|
||||
# Filtres spécifiques
|
||||
if name and not _contains(iname, name):
|
||||
continue
|
||||
if email and not _contains(iemail, email):
|
||||
continue
|
||||
if company and not _contains(icompany, company):
|
||||
continue
|
||||
if status and istatus != status:
|
||||
continue
|
||||
if city and not _contains(icity, city):
|
||||
continue
|
||||
|
||||
if tags:
|
||||
# exiger que tous les tags demandés soient présents
|
||||
itags_set = set(itags)
|
||||
if not all(tag in itags_set for tag in tags):
|
||||
continue
|
||||
|
||||
if df and (not icreated_d or icreated_d < df):
|
||||
continue
|
||||
if dt and (not icreated_d or icreated_d > dt):
|
||||
continue
|
||||
|
||||
out.append(it)
|
||||
|
||||
# On peut trier par date de création desc, puis nom
|
||||
out.sort(key=lambda d: (d.get("created_at") or "", d.get("name") or ""), reverse=True)
|
||||
return out
|
||||
51
modules/crm/status.py
Normal file
51
modules/crm/status.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import unicodedata
|
||||
from enum import Enum
|
||||
from typing import Union, Optional
|
||||
|
||||
|
||||
def _normalize_no_accents(s: str) -> str:
|
||||
"""Normalise une chaîne en supprimant les accents et en la passant en minuscule."""
|
||||
return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c)).lower()
|
||||
|
||||
|
||||
class ProspectStatus(str, Enum):
|
||||
NOUVEAU = "Nouveau"
|
||||
CONTACTE = "Contacté"
|
||||
RELANCE = "Relancé"
|
||||
QUALIFIE = "Qualifié"
|
||||
PROPOSITION = "Proposition"
|
||||
NON_INTERESSE = "Non intéressé"
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, value: Optional[Union["ProspectStatus", str]]) -> "ProspectStatus":
|
||||
"""Convertit une valeur (enum ou chaîne) en ProspectStatus, avec tolérance sur la casse/accents."""
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
if value is None:
|
||||
return cls.NOUVEAU
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return cls.NOUVEAU
|
||||
|
||||
n = _normalize_no_accents(s)
|
||||
|
||||
if n in {"nouveau", "new"}:
|
||||
return cls.NOUVEAU
|
||||
if n in {"contacte", "contacted"}:
|
||||
return cls.CONTACTE
|
||||
if n in {"relance", "relaunched"}:
|
||||
return cls.RELANCE
|
||||
if n in {"qualifie", "qualified"}:
|
||||
return cls.QUALIFIE
|
||||
if n in {"proposition", "proposal"}:
|
||||
return cls.PROPOSITION
|
||||
if n in {"non interesse", "noninteresse", "not interested", "refuse", "refus", "abandon"}:
|
||||
return cls.NON_INTERESSE
|
||||
|
||||
# Tentative de correspondance exacte insensible aux accents/casse avec les valeurs de l'enum
|
||||
for member in cls:
|
||||
if _normalize_no_accents(member.value) == n:
|
||||
return member
|
||||
|
||||
# Valeur inconnue -> par défaut
|
||||
return cls.NOUVEAU
|
||||
78
modules/crm/web.py
Normal file
78
modules/crm/web.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from flask import Blueprint, request, jsonify, url_for
|
||||
from typing import List
|
||||
from modules.crm.search import filter_prospects
|
||||
|
||||
crm_api_bp = Blueprint("crm_api", __name__, url_prefix="/api/crm")
|
||||
|
||||
|
||||
@crm_api_bp.get("/prospects/search")
|
||||
def prospects_search():
|
||||
"""
|
||||
API de recherche/filtre prospects.
|
||||
Paramètres (query string):
|
||||
- q: recherche globale (nom, email, société, ville)
|
||||
- name, email, company, status, city: filtres spécifiques (contient, sauf status: égal insensible à la casse)
|
||||
- tags: liste séparée par des virgules (tous doivent être présents)
|
||||
- date_from, date_to: période sur created_at (YYYY-MM-DD)
|
||||
"""
|
||||
q = request.args.get("q")
|
||||
name = request.args.get("name")
|
||||
email = request.args.get("email")
|
||||
company = request.args.get("company")
|
||||
status = request.args.get("status")
|
||||
city = request.args.get("city")
|
||||
tags_raw = request.args.get("tags") or ""
|
||||
tags: List[str] = [t.strip() for t in tags_raw.split(",")] if tags_raw else []
|
||||
date_from = request.args.get("date_from") or None
|
||||
date_to = request.args.get("date_to") or None
|
||||
|
||||
results = filter_prospects(
|
||||
q=q,
|
||||
name=name,
|
||||
email=email,
|
||||
company=company,
|
||||
status=status,
|
||||
city=city,
|
||||
tags=tags,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
# Enrichit avec des URLs navigables
|
||||
enriched = []
|
||||
for it in results:
|
||||
new_it = dict(it)
|
||||
# URL principale (prospect)
|
||||
try:
|
||||
new_it["url"] = url_for("prospect_details", prospect_id=it.get("id"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
related = []
|
||||
cid = it.get("client_id")
|
||||
if cid:
|
||||
try:
|
||||
related.append({"type": "client", "url": url_for("client_details", client_id=cid)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pids = it.get("project_ids") or []
|
||||
for pid in pids:
|
||||
try:
|
||||
related.append({"type": "project", "url": url_for("project_details", project_id=pid)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if related:
|
||||
new_it["related"] = related
|
||||
|
||||
# S'assure que le type est présent
|
||||
new_it["entity_type"] = new_it.get("entity_type") or "prospect"
|
||||
enriched.append(new_it)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"count": len(enriched),
|
||||
"results": enriched,
|
||||
}
|
||||
)
|
||||
88
modules/devis/app.py
Normal file
88
modules/devis/app.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
def main():
|
||||
print("=== Générateur de devis ===\n")
|
||||
|
||||
# Importation des modules nécessaires
|
||||
from core.form import Form
|
||||
from core.generator import Generator
|
||||
from core.data import Data
|
||||
import os
|
||||
|
||||
# On demande d'abord si le client existe déjà
|
||||
client_name = input("Entrez le nom du client : ")
|
||||
client_name = client_name.replace(" ", "_").lower() # Normaliser le nom du client
|
||||
client_data_path = f"Data/clients/{client_name}.json"
|
||||
if os.path.exists(client_data_path):
|
||||
print(f"Le client {client_name} existe déjà. Chargement des données...")
|
||||
data_manager = Data(client_data_path)
|
||||
client_data = data_manager.load_data()
|
||||
else:
|
||||
print(f"Le client {client_name} n'existe pas. Création d'un nouveau client...")
|
||||
#On charge le module proposition pour créer un nouveau client
|
||||
from modules.proposition.app import main as generate_proposal
|
||||
generate_proposal()
|
||||
data_manager = Data(client_data_path)
|
||||
client_data = data_manager.load_data()
|
||||
print(f"Le client {client_name} a été créé avec succès.")
|
||||
|
||||
# On génère le devis via les données du client en servant du template "devis" du dossier Templates
|
||||
if client_data:
|
||||
print("\n=== Création du devis ===")
|
||||
|
||||
# Charger le modèle de devis
|
||||
from core.generator import Generator
|
||||
template_path = "Templates/devis.json"
|
||||
|
||||
# Préparer les champs pour le formulaire
|
||||
fields = [
|
||||
{"name": "numero", "label": "Numéro du devis", "type": "text"},
|
||||
]
|
||||
|
||||
# Collecter les données via le formulaire
|
||||
form = Form(fields)
|
||||
form.ask()
|
||||
form_data = form.get_data()
|
||||
|
||||
# Préparer les données du client pour le modèle
|
||||
client_data_formatted = {
|
||||
"client_name": client_data.get("client_name", ""),
|
||||
"client_email": client_data.get("email", ""),
|
||||
"client_phone": client_data.get("telephone", ""),
|
||||
"client_adress": client_data.get("adresse", "")
|
||||
}
|
||||
|
||||
# Récupérer les fonctionnalités du client
|
||||
features = client_data.get("features", [])
|
||||
if not isinstance(features, list):
|
||||
print("Erreur : Les fonctionnalités du client ne sont pas correctement formatées. Elles seront ignorées.")
|
||||
features = []
|
||||
|
||||
# Ajouter les services avec tarifs pour chaque fonctionnalité
|
||||
services = []
|
||||
for feature in features:
|
||||
if isinstance(feature, dict): # Vérifier que 'feature' est un dictionnaire
|
||||
description = feature.get("description", "")
|
||||
prix_unitaire = float(input(f"Entrez le tarif pour la fonctionnalité '{description}' : "))
|
||||
services.append({"description": description, "prix_unitaire": prix_unitaire})
|
||||
else:
|
||||
print(f"Erreur : La fonctionnalité '{feature}' n'est pas valide et sera ignorée.")
|
||||
|
||||
# Ajouter les données spécifiques au devis
|
||||
devis_content = {
|
||||
"numero": form_data["numero"],
|
||||
"date": "11/05/2025",
|
||||
"client": client_data_formatted, # Transmettre les données formatées du client
|
||||
"services": services,
|
||||
"total": sum(service["prix_unitaire"] for service in services)
|
||||
}
|
||||
|
||||
# Générer le devis
|
||||
output_path = f"devis"
|
||||
try:
|
||||
generator = Generator(devis_content, template_path)
|
||||
generator.generate_facture(output_path)
|
||||
print("\n✅ Devis généré avec succès.")
|
||||
print(f"Le devis a été enregistré dans le dossier '{output_path}'.")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Une erreur est survenue lors de la génération du devis : {e}")
|
||||
else:
|
||||
print("\n❌ Impossible de générer le devis car les données du client n'ont pas été chargées.")
|
||||
0
modules/devis/fields.py
Normal file
0
modules/devis/fields.py
Normal file
70
modules/email/draft.py
Normal file
70
modules/email/draft.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class EmailDraft:
|
||||
"""
|
||||
Représente un brouillon d'email généré automatiquement.
|
||||
Stockage fichier: Data/email_drafts/<id>.json
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
prospect_id: str,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
content: str,
|
||||
status: str = "draft", # draft | sent | failed
|
||||
template_id: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
created_at: Optional[str] = None,
|
||||
sent_at: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.id = id or f"ed_{uuid.uuid4().hex[:10]}"
|
||||
self.prospect_id = prospect_id
|
||||
self.to_email = to_email
|
||||
self.subject = subject
|
||||
self.content = content
|
||||
self.status = status
|
||||
self.template_id = template_id
|
||||
self.task_id = task_id
|
||||
self.created_at = created_at or datetime.utcnow().isoformat()
|
||||
self.sent_at = sent_at
|
||||
self.error_message = error_message
|
||||
self.metadata = metadata or {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"prospect_id": self.prospect_id,
|
||||
"to_email": self.to_email,
|
||||
"subject": self.subject,
|
||||
"content": self.content,
|
||||
"status": self.status,
|
||||
"template_id": self.template_id,
|
||||
"task_id": self.task_id,
|
||||
"created_at": self.created_at,
|
||||
"sent_at": self.sent_at,
|
||||
"error_message": self.error_message,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "EmailDraft":
|
||||
return EmailDraft(
|
||||
id=data.get("id"),
|
||||
prospect_id=data.get("prospect_id", ""),
|
||||
to_email=data.get("to_email", ""),
|
||||
subject=data.get("subject", ""),
|
||||
content=data.get("content", ""),
|
||||
status=data.get("status", "draft"),
|
||||
template_id=data.get("template_id"),
|
||||
task_id=data.get("task_id"),
|
||||
created_at=data.get("created_at"),
|
||||
sent_at=data.get("sent_at"),
|
||||
error_message=data.get("error_message"),
|
||||
metadata=data.get("metadata") or {},
|
||||
)
|
||||
89
modules/email/draft_handler.py
Normal file
89
modules/email/draft_handler.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import os
|
||||
import json
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from modules.email.draft import EmailDraft
|
||||
|
||||
|
||||
class DraftHandler:
|
||||
"""
|
||||
Gestionnaire de brouillons (fichiers JSON).
|
||||
Répertoire: Data/email_drafts
|
||||
"""
|
||||
def __init__(self, base_dir: Optional[str] = None):
|
||||
base_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
self.base_dir = base_dir or os.path.join(base_root, "Data", "email_drafts")
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
||||
def _draft_path(self, draft_id: str) -> str:
|
||||
return os.path.join(self.base_dir, f"{draft_id}.json")
|
||||
|
||||
def add_draft(self, draft: EmailDraft) -> str:
|
||||
path = self._draft_path(draft.id)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(draft.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
return draft.id
|
||||
|
||||
def get_draft(self, draft_id: str) -> Optional[EmailDraft]:
|
||||
path = self._draft_path(draft_id)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return EmailDraft.from_dict(json.load(f))
|
||||
|
||||
def update_draft(self, draft: EmailDraft) -> bool:
|
||||
path = self._draft_path(draft.id)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(draft.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
|
||||
def delete_draft(self, draft_id: str) -> bool:
|
||||
path = self._draft_path(draft_id)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def list_drafts(self, status: Optional[str] = None) -> List[EmailDraft]:
|
||||
drafts: List[EmailDraft] = []
|
||||
for filename in os.listdir(self.base_dir):
|
||||
if filename.endswith(".json"):
|
||||
try:
|
||||
with open(os.path.join(self.base_dir, filename), "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
d = EmailDraft.from_dict(data)
|
||||
if status is None or d.status == status:
|
||||
drafts.append(d)
|
||||
except Exception:
|
||||
continue
|
||||
# Tri: plus récents d'abord
|
||||
drafts.sort(key=lambda d: d.created_at or "", reverse=True)
|
||||
return drafts
|
||||
|
||||
def list_pending(self) -> List[EmailDraft]:
|
||||
return self.list_drafts(status="draft")
|
||||
|
||||
def mark_sent(self, draft_id: str, success: bool, error_message: Optional[str] = None) -> bool:
|
||||
d = self.get_draft(draft_id)
|
||||
if not d:
|
||||
return False
|
||||
d.status = "sent" if success else "failed"
|
||||
d.sent_at = datetime.utcnow().isoformat()
|
||||
d.error_message = None if success else (error_message or "Unknown error")
|
||||
return self.update_draft(d)
|
||||
|
||||
def find_existing_for_task(self, task_id: str) -> Optional[EmailDraft]:
|
||||
"""
|
||||
Évite les doublons: si un draft 'draft' existe déjà pour cette tâche, le renvoie.
|
||||
Les brouillons 'failed' ne bloquent pas la régénération.
|
||||
"""
|
||||
for d in self.list_drafts():
|
||||
if d.task_id == task_id and d.status == "draft":
|
||||
return d
|
||||
return None
|
||||
87
modules/email/drafts_web.py
Normal file
87
modules/email/drafts_web.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from flask import Blueprint, request, redirect, url_for, flash, Response
|
||||
from typing import List
|
||||
from html import escape
|
||||
|
||||
from modules.email.draft_handler import DraftHandler
|
||||
from modules.email.email_manager import EmailSender
|
||||
|
||||
email_drafts_bp = Blueprint("email_drafts", __name__, url_prefix="/email/drafts")
|
||||
|
||||
|
||||
@email_drafts_bp.get("/")
|
||||
def list_drafts_page():
|
||||
"""
|
||||
Page HTML minimaliste listant les brouillons avec bouton [Envoyer].
|
||||
Pas de template Jinja requis (HTML inline pour simplicité d'intégration).
|
||||
"""
|
||||
handler = DraftHandler()
|
||||
drafts = handler.list_pending()
|
||||
|
||||
def row_html(d):
|
||||
# Contenu HTML tel quel (on suppose content déjà HTML sûr)
|
||||
return f"""
|
||||
<div style="border:1px solid #ddd; padding:12px; margin-bottom:12px; border-radius:8px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3 style="margin:0; font-size:1.05rem;">{escape(d.subject)}</h3>
|
||||
<form method="post" action="{url_for('email_drafts.send_draft')}">
|
||||
<input type="hidden" name="draft_id" value="{escape(d.id)}" />
|
||||
<button type="submit" style="padding:6px 12px;">Envoyer</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="color:#555; margin:6px 0 8px 0;">À: {escape(d.to_email)}</div>
|
||||
<div style="background:#fafafa; padding:10px; border-radius:6px;">{d.content}</div>
|
||||
<div style="font-size:12px; color:#777; margin-top:6px;">
|
||||
Prospect: {escape(d.prospect_id)} | Template: {escape(d.template_id or '-') }
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
items_html = "\n".join(row_html(d) for d in drafts) or "<p>Aucun brouillon à envoyer.</p>"
|
||||
|
||||
page = f"""
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Brouillons d'emails</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body style="max-width:920px; margin: 20px auto; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;">
|
||||
<h2>Brouillons d'emails à envoyer</h2>
|
||||
<div>{items_html}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return Response(page, mimetype="text/html")
|
||||
|
||||
|
||||
@email_drafts_bp.post("/send")
|
||||
def send_draft():
|
||||
"""
|
||||
Envoi d'un brouillon sélectionné, puis mise à jour du statut.
|
||||
"""
|
||||
draft_id = (request.form.get("draft_id") or "").strip()
|
||||
if not draft_id:
|
||||
flash("Brouillon invalide", "warning")
|
||||
return redirect(url_for("email_drafts.list_drafts_page"))
|
||||
|
||||
handler = DraftHandler()
|
||||
draft = handler.get_draft(draft_id)
|
||||
if not draft:
|
||||
flash("Brouillon introuvable", "danger")
|
||||
return redirect(url_for("email_drafts.list_drafts_page"))
|
||||
|
||||
sender = EmailSender()
|
||||
try:
|
||||
res = sender.send_email(draft.to_email, draft.subject, draft.content)
|
||||
if res.get("success"):
|
||||
handler.mark_sent(draft.id, success=True)
|
||||
flash("Email envoyé.", "success")
|
||||
else:
|
||||
handler.mark_sent(draft.id, success=False, error_message=res.get("error"))
|
||||
flash("Échec de l'envoi de l'email.", "danger")
|
||||
except Exception as e:
|
||||
handler.mark_sent(draft.id, success=False, error_message=str(e))
|
||||
flash("Erreur lors de l'envoi de l'email.", "danger")
|
||||
|
||||
return redirect(url_for("email_drafts.list_drafts_page"))
|
||||
353
modules/email/email_manager.py
Normal file
353
modules/email/email_manager.py
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
from typing import List, Dict, Any, Union
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import quote_plus
|
||||
from uuid import uuid4
|
||||
from core.data import Data
|
||||
|
||||
class EmailTemplate:
|
||||
"""Classe gérant les templates d'emails"""
|
||||
|
||||
def __init__(self, template_folder="Data/email_templates"):
|
||||
self.template_folder = template_folder
|
||||
|
||||
# Créer le dossier de templates s'il n'existe pas
|
||||
if not os.path.exists(self.template_folder):
|
||||
os.makedirs(self.template_folder)
|
||||
|
||||
def get_all_templates(self):
|
||||
"""Retourne tous les templates disponibles"""
|
||||
templates = []
|
||||
if os.path.exists(self.template_folder):
|
||||
for filename in os.listdir(self.template_folder):
|
||||
if filename.endswith('.json'):
|
||||
template_path = os.path.join(self.template_folder, filename)
|
||||
try:
|
||||
data_manager = Data(template_path)
|
||||
template_data = data_manager.load_data()
|
||||
templates.append(template_data)
|
||||
except Exception as e:
|
||||
print(f"Erreur lors du chargement du template {filename}: {e}")
|
||||
return templates
|
||||
|
||||
def get_template_by_id(self, template_id):
|
||||
"""Récupère un template par son ID"""
|
||||
template_path = os.path.join(self.template_folder, f"{template_id}.json")
|
||||
if os.path.exists(template_path):
|
||||
data_manager = Data(template_path)
|
||||
return data_manager.load_data()
|
||||
return None
|
||||
|
||||
def save_template(self, template_data):
|
||||
"""Sauvegarde un template d'email"""
|
||||
template_id = template_data.get('id')
|
||||
if not template_id:
|
||||
# Générer un ID s'il n'existe pas
|
||||
import uuid
|
||||
template_id = f"tpl_{uuid.uuid4().hex[:8]}"
|
||||
template_data['id'] = template_id
|
||||
|
||||
template_path = os.path.join(self.template_folder, f"{template_id}.json")
|
||||
data_manager = Data(template_path)
|
||||
data_manager.save_data(template_data)
|
||||
return template_data
|
||||
|
||||
def delete_template(self, template_id):
|
||||
"""Supprime un template d'email"""
|
||||
template_path = os.path.join(self.template_folder, f"{template_id}.json")
|
||||
if os.path.exists(template_path):
|
||||
os.remove(template_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def render_template(self, template_id, context=None):
|
||||
"""Rend un template avec les variables spécifiées dans le contexte"""
|
||||
template = self.get_template_by_id(template_id)
|
||||
if not template:
|
||||
return None
|
||||
|
||||
subject = template.get('subject', '')
|
||||
content = template.get('content', '')
|
||||
|
||||
# Remplacer les variables dans le sujet et le contenu
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
subject = subject.replace(placeholder, str(value))
|
||||
content = content.replace(placeholder, str(value))
|
||||
|
||||
return {
|
||||
"subject": subject,
|
||||
"content": content
|
||||
}
|
||||
|
||||
|
||||
class EmailSender:
|
||||
"""Classe gérant l'envoi d'emails"""
|
||||
|
||||
def __init__(self, config_file="config/email_config.json"):
|
||||
# Ensure config_file is an absolute path
|
||||
if not os.path.isabs(config_file):
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
self.config_file = os.path.join(base_dir, config_file)
|
||||
else:
|
||||
self.config_file = config_file
|
||||
self.config = self._load_config()
|
||||
self.template_manager = EmailTemplate()
|
||||
|
||||
def _load_config(self):
|
||||
"""Charge la configuration email depuis le fichier de configuration"""
|
||||
config_dir = os.path.dirname(self.config_file)
|
||||
if not os.path.exists(config_dir):
|
||||
os.makedirs(config_dir)
|
||||
|
||||
print(f"Loading email config from: {self.config_file}")
|
||||
if os.path.exists(self.config_file):
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
print(f"Loaded email config: {config}")
|
||||
return config
|
||||
except Exception as e:
|
||||
print(f"Erreur lors du chargement de la configuration email: {e}")
|
||||
|
||||
# Configuration par défaut
|
||||
default_config = {
|
||||
"smtp_server": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"sender_name": "Suite Consultance",
|
||||
"sender_email": ""
|
||||
}
|
||||
print(f"Using default email config: {default_config}")
|
||||
return default_config
|
||||
|
||||
def save_config(self, config):
|
||||
"""Sauvegarde la configuration email"""
|
||||
config_dir = os.path.dirname(self.config_file)
|
||||
if not os.path.exists(config_dir):
|
||||
os.makedirs(config_dir)
|
||||
|
||||
with open(self.config_file, 'w') as f:
|
||||
json.dump(config, f, indent=4)
|
||||
|
||||
self.config = config
|
||||
return True
|
||||
|
||||
def send_email(self, to_email, subject, body, cc=None, bcc=None):
|
||||
"""Envoie un email à un destinataire"""
|
||||
if not self.config.get('username') or not self.config.get('password'):
|
||||
raise ValueError("La configuration email n'est pas complète")
|
||||
|
||||
message = MIMEMultipart()
|
||||
message["From"] = f"{self.config.get('sender_name')} <{self.config.get('sender_email')}>"
|
||||
message["To"] = to_email
|
||||
message["Subject"] = subject
|
||||
|
||||
if cc:
|
||||
message["Cc"] = ", ".join(cc) if isinstance(cc, list) else cc
|
||||
if bcc:
|
||||
message["Bcc"] = ", ".join(bcc) if isinstance(bcc, list) else bcc
|
||||
|
||||
message.attach(MIMEText(body, "html"))
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP(self.config.get('smtp_server'), self.config.get('smtp_port'))
|
||||
server.starttls()
|
||||
server.login(self.config.get('username'), self.config.get('password'))
|
||||
|
||||
recipients = [to_email]
|
||||
if cc:
|
||||
recipients.extend(cc if isinstance(cc, list) else [cc])
|
||||
if bcc:
|
||||
recipients.extend(bcc if isinstance(bcc, list) else [bcc])
|
||||
|
||||
server.sendmail(self.config.get('sender_email'), recipients, message.as_string())
|
||||
server.quit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"to": to_email,
|
||||
"subject": subject
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def send_templated_email(self, to_email, template_id, context=None, cc=None, bcc=None):
|
||||
"""Envoie un email basé sur un template à un destinataire"""
|
||||
rendered = self.template_manager.render_template(template_id, context)
|
||||
if not rendered:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Template not found",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.send_email(to_email, rendered['subject'], rendered['content'], cc, bcc)
|
||||
|
||||
def send_bulk_email(self, emails, subject, body, cc=None, bcc=None):
|
||||
"""Envoie le même email à plusieurs destinataires"""
|
||||
results = []
|
||||
for email in emails:
|
||||
result = self.send_email(email, subject, body, cc, bcc)
|
||||
results.append({
|
||||
"email": email,
|
||||
**result
|
||||
})
|
||||
return results
|
||||
|
||||
def send_bulk_templated_email(self, recipients, template_id, cc=None, bcc=None):
|
||||
"""
|
||||
Envoie un email basé sur un template à plusieurs destinataires
|
||||
recipients: liste de dictionnaires contenant l'email du destinataire et le contexte
|
||||
[{
|
||||
"email": "example@example.com",
|
||||
"context": {"name": "John Doe", "company": "ACME Inc."}
|
||||
}]
|
||||
"""
|
||||
results = []
|
||||
for recipient in recipients:
|
||||
email = recipient.get('email')
|
||||
context = recipient.get('context', {})
|
||||
result = self.send_templated_email(email, template_id, context, cc, bcc)
|
||||
results.append({
|
||||
"email": email,
|
||||
**result
|
||||
})
|
||||
return results
|
||||
|
||||
# ---------- Tracking helpers ----------
|
||||
def _embed_tracking(self, html_body: str, tracking_id: str, prospect_id: str) -> str:
|
||||
"""
|
||||
Ajoute un pixel d'ouverture et réécrit les liens pour le click tracking.
|
||||
Utilise APP_BASE_URL si définie, sinon génère des liens relatifs.
|
||||
"""
|
||||
base = (os.environ.get("APP_BASE_URL") or "").rstrip("/")
|
||||
prefix = f"{base}/tasks/t" # routes de tracking montées sur le blueprint 'tasks'
|
||||
# Pixel d'ouverture (1x1 PNG)
|
||||
pixel = f'<img src="{prefix}/o/{tracking_id}.png?pid={quote_plus(prospect_id)}" alt="" width="1" height="1" style="display:none;" />'
|
||||
body = html_body or ""
|
||||
# Injection du pixel avant la fermeture de body si possible
|
||||
if "</body>" in body.lower():
|
||||
# trouver la vraie balise en conservant la casse
|
||||
idx = body.lower().rfind("</body>")
|
||||
body = body[:idx] + pixel + body[idx:]
|
||||
else:
|
||||
body = body + pixel
|
||||
|
||||
# Réécriture des liens <a href="...">
|
||||
def _rewrite(match):
|
||||
url = match.group(1)
|
||||
# ignore si déjà tracké
|
||||
if "/tasks/t/c/" in url:
|
||||
return f'href="{url}"'
|
||||
tracked = f'{prefix}/c/{tracking_id}?u={quote_plus(url)}'
|
||||
return f'href="{tracked}"'
|
||||
|
||||
body = re.sub(r'href="([^"]+)"', _rewrite, body)
|
||||
return body
|
||||
|
||||
def send_tracked_email(self, to_email: str, subject: str, body: str, prospect_id: str, template_id: str = None, cc=None, bcc=None) -> Dict[str, Any]:
|
||||
"""
|
||||
Envoie un email avec tracking (open/click).
|
||||
Crée un enregistrement de tracking et insère un pixel + réécriture des liens.
|
||||
"""
|
||||
tracking_id = f"trk_{uuid4().hex[:16]}"
|
||||
# Créer l'enregistrement de tracking
|
||||
try:
|
||||
from modules.tracking.store import TrackingStore
|
||||
store = TrackingStore()
|
||||
store.create_record(tracking_id, {
|
||||
"prospect_id": prospect_id,
|
||||
"to": to_email,
|
||||
"subject": subject,
|
||||
"template_id": template_id,
|
||||
"opens": 0,
|
||||
"clicks": 0,
|
||||
})
|
||||
except Exception:
|
||||
# même si le tracking store échoue, on tente d'envoyer l'email
|
||||
pass
|
||||
|
||||
tracked_body = self._embed_tracking(body, tracking_id, prospect_id)
|
||||
result = self.send_email(to_email, subject, tracked_body, cc, bcc)
|
||||
result["tracking_id"] = tracking_id
|
||||
return result
|
||||
|
||||
|
||||
class EmailHistory:
|
||||
"""Classe gérant l'historique des emails envoyés"""
|
||||
|
||||
def __init__(self, history_folder="Data/email_history"):
|
||||
self.history_folder = history_folder
|
||||
|
||||
# Créer le dossier d'historique s'il n'existe pas
|
||||
if not os.path.exists(self.history_folder):
|
||||
os.makedirs(self.history_folder)
|
||||
|
||||
def add_email_record(self, prospect_id, email_data):
|
||||
"""Ajoute un email à l'historique d'un prospect"""
|
||||
history_file = os.path.join(self.history_folder, f"{prospect_id}.json")
|
||||
|
||||
# Charger l'historique existant
|
||||
history = []
|
||||
if os.path.exists(history_file):
|
||||
try:
|
||||
with open(history_file, 'r') as f:
|
||||
history = json.load(f)
|
||||
except:
|
||||
history = []
|
||||
|
||||
# Ajouter le nouvel email à l'historique
|
||||
history.append({
|
||||
**email_data,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Sauvegarder l'historique
|
||||
with open(history_file, 'w') as f:
|
||||
json.dump(history, f, indent=4)
|
||||
|
||||
return True
|
||||
|
||||
def get_prospect_email_history(self, prospect_id):
|
||||
"""Récupère l'historique des emails pour un prospect"""
|
||||
history_file = os.path.join(self.history_folder, f"{prospect_id}.json")
|
||||
|
||||
if os.path.exists(history_file):
|
||||
try:
|
||||
with open(history_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
def get_all_email_history(self):
|
||||
"""Récupère l'historique de tous les emails envoyés"""
|
||||
all_history = {}
|
||||
|
||||
if os.path.exists(self.history_folder):
|
||||
for filename in os.listdir(self.history_folder):
|
||||
if filename.endswith('.json'):
|
||||
prospect_id = filename.split('.')[0]
|
||||
history_file = os.path.join(self.history_folder, filename)
|
||||
|
||||
try:
|
||||
with open(history_file, 'r') as f:
|
||||
all_history[prospect_id] = json.load(f)
|
||||
except:
|
||||
all_history[prospect_id] = []
|
||||
|
||||
return all_history
|
||||
968
modules/email/email_scraper.py
Normal file
968
modules/email/email_scraper.py
Normal file
|
|
@ -0,0 +1,968 @@
|
|||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
import time
|
||||
from typing import List, Set, Dict
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
class EmailScraper:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
})
|
||||
self.email_pattern = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}')
|
||||
self.phone_pattern = re.compile(r'(?:\+32|0)\s?[1-9](?:[\s\-\.\/]?\d){8}|\+32\s?[1-9](?:[\s\-\.\/]?\d){8}|(?:\+33|0)[1-9](?:[\s\-\.\/]?\d){8}')
|
||||
self.visited_urls = set()
|
||||
self.found_emails = set()
|
||||
self.contact_info = {}
|
||||
|
||||
def scrape_page(self, url: str, max_pages: int = 10) -> Dict:
|
||||
"""
|
||||
Scrape une page avec pagination pour extraire les données d'entreprises
|
||||
"""
|
||||
results = {
|
||||
'url': url,
|
||||
'contacts': [], # Liste des contacts avec email, nom, téléphone, etc.
|
||||
'pages_scraped': [],
|
||||
'errors': [],
|
||||
'start_time': datetime.now().isoformat(),
|
||||
'end_time': None,
|
||||
'domain_info': {}
|
||||
}
|
||||
|
||||
try:
|
||||
self._scrape_with_pagination(url, results, max_pages)
|
||||
self._extract_domain_info(url, results)
|
||||
except Exception as e:
|
||||
results['errors'].append(f"Erreur générale: {str(e)}")
|
||||
|
||||
results['end_time'] = datetime.now().isoformat()
|
||||
|
||||
return results
|
||||
|
||||
def _scrape_with_pagination(self, base_url: str, results: Dict, max_pages: int):
|
||||
"""
|
||||
Scraper avec gestion de la pagination
|
||||
"""
|
||||
current_page = 1
|
||||
current_url = base_url
|
||||
|
||||
while current_page <= max_pages:
|
||||
if current_url in self.visited_urls:
|
||||
break
|
||||
|
||||
try:
|
||||
# Normaliser l'URL
|
||||
parsed_url = urlparse(current_url)
|
||||
if not parsed_url.scheme:
|
||||
current_url = 'https://' + current_url
|
||||
|
||||
self.visited_urls.add(current_url)
|
||||
|
||||
print(f"Scraping page {current_page}: {current_url}")
|
||||
|
||||
# Faire la requête
|
||||
response = self.session.get(current_url, timeout=15)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parser le HTML
|
||||
soup = BeautifulSoup(response.content, 'html.parser')
|
||||
|
||||
# Extraire les entreprises/contacts de la page
|
||||
page_contacts = self._extract_business_contacts(soup, response.text, current_url)
|
||||
|
||||
# Ajouter les contacts à la liste principale
|
||||
for contact in page_contacts:
|
||||
# Vérifier si ce contact existe déjà (par email)
|
||||
existing_contact = next((c for c in results['contacts'] if c['email'] == contact['email']), None)
|
||||
if existing_contact:
|
||||
# Fusionner les informations si le contact existe
|
||||
self._merge_contact_info(existing_contact, contact)
|
||||
else:
|
||||
results['contacts'].append(contact)
|
||||
|
||||
results['pages_scraped'].append({
|
||||
'url': current_url,
|
||||
'page_number': current_page,
|
||||
'contacts_found': len(page_contacts),
|
||||
'contacts': page_contacts,
|
||||
'status': 'success',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
print(f" - Page {current_page}: Trouvé {len(page_contacts)} contact(s)")
|
||||
|
||||
# Si aucun contact trouvé, peut-être qu'on a atteint la fin
|
||||
if len(page_contacts) == 0:
|
||||
print(f" - Aucun contact trouvé sur la page {current_page}, arrêt du scraping")
|
||||
break
|
||||
|
||||
# Chercher le lien vers la page suivante
|
||||
next_url = self._find_next_page_url(soup, current_url, current_page)
|
||||
|
||||
if not next_url:
|
||||
print(f" - Pas de page suivante trouvée, arrêt du scraping")
|
||||
break
|
||||
|
||||
current_url = next_url
|
||||
current_page += 1
|
||||
|
||||
# Délai entre les pages pour éviter la surcharge
|
||||
time.sleep(2)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
results['errors'].append(f"Erreur de requête pour la page {current_page} ({current_url}): {str(e)}")
|
||||
results['pages_scraped'].append({
|
||||
'url': current_url,
|
||||
'page_number': current_page,
|
||||
'contacts_found': 0,
|
||||
'contacts': [],
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
break
|
||||
except Exception as e:
|
||||
results['errors'].append(f"Erreur lors du parsing de la page {current_page}: {str(e)}")
|
||||
break
|
||||
|
||||
def _extract_business_contacts(self, soup: BeautifulSoup, text: str, page_url: str) -> List[Dict]:
|
||||
"""
|
||||
Extraire les informations d'entreprises d'une page (spécialisé pour les annuaires)
|
||||
"""
|
||||
contacts = []
|
||||
|
||||
# Chercher des conteneurs d'entreprises communs
|
||||
business_containers = self._find_business_containers(soup)
|
||||
|
||||
if business_containers:
|
||||
# Si on trouve des conteneurs structurés, les traiter
|
||||
for container in business_containers:
|
||||
contact = self._extract_contact_from_container(container, page_url)
|
||||
if contact and contact.get('email'):
|
||||
contacts.append(contact)
|
||||
else:
|
||||
# Fallback: extraction générale comme avant
|
||||
contacts = self._extract_contact_info(soup, text, page_url)
|
||||
|
||||
return contacts
|
||||
|
||||
def _find_business_containers(self, soup: BeautifulSoup) -> List:
|
||||
"""
|
||||
Trouver les conteneurs qui contiennent probablement des informations d'entreprises
|
||||
"""
|
||||
containers = []
|
||||
|
||||
# Patterns communs pour les annuaires d'entreprises
|
||||
business_selectors = [
|
||||
# Classes/IDs communs
|
||||
'[class*="business"]',
|
||||
'[class*="company"]',
|
||||
'[class*="enterprise"]',
|
||||
'[class*="contact"]',
|
||||
'[class*="listing"]',
|
||||
'[class*="directory"]',
|
||||
'[class*="card"]',
|
||||
'[class*="item"]',
|
||||
'[class*="entry"]',
|
||||
'[class*="result"]',
|
||||
# Balises sémantiques
|
||||
'article',
|
||||
'[itemtype*="Organization"]',
|
||||
'[itemtype*="LocalBusiness"]',
|
||||
# Structures de liste
|
||||
'li[class*="business"]',
|
||||
'li[class*="company"]',
|
||||
'div[class*="row"]',
|
||||
'div[class*="col"]'
|
||||
]
|
||||
|
||||
for selector in business_selectors:
|
||||
try:
|
||||
elements = soup.select(selector)
|
||||
for element in elements:
|
||||
# Vérifier si l'élément contient des informations utiles
|
||||
if self._container_has_business_info(element):
|
||||
containers.append(element)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Déduplication basée sur le contenu
|
||||
unique_containers = []
|
||||
for container in containers:
|
||||
if not any(self._containers_are_similar(container, existing) for existing in unique_containers):
|
||||
unique_containers.append(container)
|
||||
|
||||
return unique_containers[:50] # Limiter pour éviter la surcharge
|
||||
|
||||
def _container_has_business_info(self, container) -> bool:
|
||||
"""
|
||||
Vérifier si un conteneur a des informations d'entreprise
|
||||
"""
|
||||
text = container.get_text(strip=True).lower()
|
||||
|
||||
# Indicateurs d'informations d'entreprise
|
||||
business_indicators = [
|
||||
'@', 'email', 'mail', 'contact',
|
||||
'tel', 'phone', 'telephone', 'gsm',
|
||||
'rue', 'avenue', 'boulevard', 'place',
|
||||
'www.', 'http', '.com', '.be', '.fr',
|
||||
'sarl', 'sprl', 'sa', 'nv', 'bvba'
|
||||
]
|
||||
|
||||
score = sum(1 for indicator in business_indicators if indicator in text)
|
||||
return score >= 2 and len(text) > 20
|
||||
|
||||
def _containers_are_similar(self, container1, container2) -> bool:
|
||||
"""
|
||||
Vérifier si deux conteneurs sont similaires (pour éviter les doublons)
|
||||
"""
|
||||
text1 = container1.get_text(strip=True)
|
||||
text2 = container2.get_text(strip=True)
|
||||
|
||||
# Si les textes sont identiques ou très similaires
|
||||
if text1 == text2:
|
||||
return True
|
||||
|
||||
# Si un conteneur est inclus dans l'autre
|
||||
if len(text1) > len(text2):
|
||||
return text2 in text1
|
||||
else:
|
||||
return text1 in text2
|
||||
|
||||
def _extract_contact_from_container(self, container, page_url: str) -> Dict:
|
||||
"""
|
||||
Extraire les informations de contact d'un conteneur spécifique
|
||||
"""
|
||||
contact = {
|
||||
'email': '',
|
||||
'name': '',
|
||||
'first_name': '',
|
||||
'last_name': '',
|
||||
'company': '',
|
||||
'phone': '',
|
||||
'location': '',
|
||||
'source_url': page_url,
|
||||
'notes': ''
|
||||
}
|
||||
|
||||
# Extraire l'email depuis les balises individuelles d'abord
|
||||
email_found = False
|
||||
|
||||
# Chercher dans les liens mailto
|
||||
mailto_links = container.find_all('a', href=re.compile(r'^mailto:', re.I))
|
||||
if mailto_links:
|
||||
href = mailto_links[0].get('href', '')
|
||||
email_match = re.search(r'mailto:([^?&]+)', href, re.I)
|
||||
if email_match and self._is_valid_email(email_match.group(1)):
|
||||
contact['email'] = email_match.group(1).lower()
|
||||
email_found = True
|
||||
|
||||
# Si pas trouvé dans mailto, chercher dans les balises individuelles
|
||||
if not email_found:
|
||||
for element in container.find_all(['p', 'div', 'span', 'td', 'li']):
|
||||
element_text = element.get_text(strip=True)
|
||||
# Ajouter des espaces autour des balises pour éviter la concaténation
|
||||
element_text = ' ' + element_text + ' '
|
||||
|
||||
email_matches = self.email_pattern.findall(element_text)
|
||||
if email_matches:
|
||||
for email in email_matches:
|
||||
email = email.strip()
|
||||
if re.match(r'^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$', email) and self._is_valid_email(email):
|
||||
contact['email'] = email.lower()
|
||||
email_found = True
|
||||
break
|
||||
if email_found:
|
||||
break
|
||||
|
||||
# Si toujours pas trouvé, chercher dans le texte global avec des patterns plus précis
|
||||
if not email_found:
|
||||
container_text = container.get_text(separator=' ', strip=True) # Utiliser un séparateur
|
||||
|
||||
# Patterns avec contexte pour éviter la capture parasite
|
||||
context_patterns = [
|
||||
r'(?:email|e-mail|mail|contact)\s*:?\s*([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})',
|
||||
r'([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})(?=\s|$|[^\w.-])',
|
||||
]
|
||||
|
||||
for pattern in context_patterns:
|
||||
matches = re.findall(pattern, container_text, re.IGNORECASE)
|
||||
if matches:
|
||||
email = matches[0] if isinstance(matches[0], str) else matches[0][0] if matches[0] else ''
|
||||
if email and self._is_valid_email(email):
|
||||
contact['email'] = email.lower()
|
||||
email_found = True
|
||||
break
|
||||
|
||||
# Extraire le téléphone
|
||||
container_text = container.get_text(separator=' ', strip=True)
|
||||
phone_matches = self.phone_pattern.findall(container_text)
|
||||
if phone_matches:
|
||||
# Prendre le premier numéro et le nettoyer
|
||||
phone = phone_matches[0]
|
||||
# S'assurer qu'on n'a que des chiffres, espaces, tirets, points, slash et +
|
||||
clean_phone = re.sub(r'[^0-9\s\-\.\/\+].*$', '', phone)
|
||||
contact['phone'] = clean_phone.strip()
|
||||
|
||||
# Extraire le nom de l'entreprise
|
||||
contact['company'] = self._extract_company_name(container, container_text)
|
||||
|
||||
# Extraire les noms de personnes
|
||||
names = self._extract_person_names(container, container_text)
|
||||
if names:
|
||||
contact.update(names)
|
||||
|
||||
# Extraire la localisation
|
||||
contact['location'] = self._extract_location_from_container(container, container_text)
|
||||
|
||||
# Enrichir avec des informations contextuelles
|
||||
self._enhance_business_contact(contact, container, container_text)
|
||||
|
||||
return contact if contact['email'] or contact['company'] else None
|
||||
|
||||
def _extract_company_name(self, container, text: str) -> str:
|
||||
"""
|
||||
Extraire le nom de l'entreprise d'un conteneur
|
||||
"""
|
||||
# Chercher dans les balises title, h1-h6, strong, b
|
||||
title_elements = container.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', '[class*="title"]', '[class*="name"]', '[class*="company"]'])
|
||||
|
||||
for element in title_elements:
|
||||
company_text = element.get_text(strip=True)
|
||||
if len(company_text) > 2 and len(company_text) < 100:
|
||||
# Éviter les textes trop génériques
|
||||
if not any(generic in company_text.lower() for generic in ['accueil', 'contact', 'email', 'téléphone', 'adresse']):
|
||||
return company_text
|
||||
|
||||
# Fallback: prendre la première ligne non-vide qui semble être un nom
|
||||
lines = text.split('\n')
|
||||
for line in lines[:3]: # Les 3 premières lignes
|
||||
line = line.strip()
|
||||
if len(line) > 2 and len(line) < 100 and not '@' in line and not any(char.isdigit() for char in line[:3]):
|
||||
return line
|
||||
|
||||
return ''
|
||||
|
||||
def _extract_person_names(self, container, text: str) -> Dict:
|
||||
"""
|
||||
Extraire les noms de personnes
|
||||
"""
|
||||
names = {'name': '', 'first_name': '', 'last_name': ''}
|
||||
|
||||
# Patterns pour les noms de personnes
|
||||
name_patterns = [
|
||||
r'\b([A-Z][a-zÀ-ÿ]+)\s+([A-Z][a-zÀ-ÿ]+)\b', # Prénom Nom
|
||||
r'\b([A-Z][A-Z]+)\s+([A-Z][a-zÀ-ÿ]+)\b', # NOM Prénom
|
||||
]
|
||||
|
||||
# Chercher dans les balises spécifiques
|
||||
name_elements = container.find_all(['[class*="name"]', '[class*="contact"]', '[class*="person"]'])
|
||||
|
||||
for element in name_elements:
|
||||
element_text = element.get_text(strip=True)
|
||||
for pattern in name_patterns:
|
||||
match = re.search(pattern, element_text)
|
||||
if match:
|
||||
names['first_name'] = match.group(1)
|
||||
names['last_name'] = match.group(2)
|
||||
names['name'] = f"{names['first_name']} {names['last_name']}"
|
||||
return names
|
||||
|
||||
# Si pas trouvé dans les balises, chercher dans le texte
|
||||
for pattern in name_patterns:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
names['first_name'] = match.group(1)
|
||||
names['last_name'] = match.group(2)
|
||||
names['name'] = f"{names['first_name']} {names['last_name']}"
|
||||
break
|
||||
|
||||
return names
|
||||
|
||||
def _extract_location_from_container(self, container, text: str) -> str:
|
||||
"""
|
||||
Extraire la localisation d'un conteneur
|
||||
"""
|
||||
# Chercher dans les balises d'adresse
|
||||
address_elements = container.find_all(['address', '[class*="address"]', '[class*="location"]', '[class*="ville"]', '[class*="city"]'])
|
||||
|
||||
for element in address_elements:
|
||||
location_text = element.get_text(strip=True)
|
||||
if len(location_text) > 5:
|
||||
return location_text
|
||||
|
||||
# Patterns pour les adresses belges/françaises
|
||||
location_patterns = [
|
||||
r'\b\d{4,5}\s+[A-Za-zÀ-ÿ\s\-]+\b', # Code postal + ville
|
||||
r'\b[A-Za-zÀ-ÿ\s\-]+,\s*[A-Za-zÀ-ÿ\s\-]+\b', # Ville, Région/Pays
|
||||
r'\b(?:rue|avenue|boulevard|place|chemin)\s+[A-Za-zÀ-ÿ\s\d\-,]+\b' # Adresse complète
|
||||
]
|
||||
|
||||
for pattern in location_patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
return ''
|
||||
|
||||
def _enhance_business_contact(self, contact: Dict, container, text: str):
|
||||
"""
|
||||
Améliorer les informations de contact d'entreprise
|
||||
"""
|
||||
# Si pas de nom trouvé, essayer d'extraire depuis l'email
|
||||
if not contact['name'] and contact['email']:
|
||||
local_part = contact['email'].split('@')[0]
|
||||
domain_part = contact['email'].split('@')[1]
|
||||
|
||||
if '.' in local_part:
|
||||
parts = local_part.split('.')
|
||||
contact['first_name'] = parts[0].title()
|
||||
contact['last_name'] = parts[1].title() if len(parts) > 1 else ''
|
||||
contact['name'] = f"{contact['first_name']} {contact['last_name']}".strip()
|
||||
|
||||
# Si pas d'entreprise, essayer de deviner depuis le domaine
|
||||
if not contact['company']:
|
||||
company_name = domain_part.split('.')[0]
|
||||
contact['company'] = company_name.title()
|
||||
|
||||
# Enrichir les notes avec des informations contextuelles
|
||||
notes_parts = []
|
||||
|
||||
# Chercher des informations sur l'activité
|
||||
activity_patterns = [
|
||||
r'(?i)\b(restaurant|café|boulangerie|pharmacie|garage|coiffeur|médecin|avocat|comptable|architecte|dentiste|vétérinaire|magasin|boutique|salon)\b',
|
||||
r'(?i)\b(commerce|service|entreprise|société|bureau|cabinet|clinique|centre|institut)\b'
|
||||
]
|
||||
|
||||
for pattern in activity_patterns:
|
||||
matches = re.findall(pattern, text)
|
||||
if matches:
|
||||
notes_parts.append(f"Activité: {', '.join(set(matches))}")
|
||||
break
|
||||
|
||||
# Chercher des horaires
|
||||
horaires_pattern = r'(?i)(?:ouvert|fermé|horaires?)[:\s]*([^.!?\n]{10,50})'
|
||||
horaires_match = re.search(horaires_pattern, text)
|
||||
if horaires_match:
|
||||
notes_parts.append(f"Horaires: {horaires_match.group(1).strip()}")
|
||||
|
||||
# Chercher un site web
|
||||
website_pattern = r'\b(?:www\.)?[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.(?:com|be|fr|org|net)\b'
|
||||
website_match = re.search(website_pattern, text)
|
||||
if website_match:
|
||||
notes_parts.append(f"Site web: {website_match.group(0)}")
|
||||
|
||||
contact['notes'] = ' | '.join(notes_parts)
|
||||
|
||||
def _find_next_page_url(self, soup: BeautifulSoup, current_url: str, current_page: int) -> str:
|
||||
"""
|
||||
Trouver l'URL de la page suivante
|
||||
"""
|
||||
base_url = '/'.join(current_url.split('/')[:-1]) if '/' in current_url else current_url
|
||||
|
||||
# Patterns communs pour les liens de pagination
|
||||
next_patterns = [
|
||||
# Liens avec texte
|
||||
'a[href]:contains("Suivant")',
|
||||
'a[href]:contains("Next")',
|
||||
'a[href]:contains(">")',
|
||||
'a[href]:contains("Page suivante")',
|
||||
# Liens avec classes
|
||||
'a[class*="next"]',
|
||||
'a[class*="suivant"]',
|
||||
'a[class*="pagination"]',
|
||||
# Numéros de page
|
||||
f'a[href]:contains("{current_page + 1}")',
|
||||
]
|
||||
|
||||
for pattern in next_patterns:
|
||||
try:
|
||||
links = soup.select(pattern)
|
||||
for link in links:
|
||||
href = link.get('href')
|
||||
if href:
|
||||
# Construire l'URL complète
|
||||
if href.startswith('http'):
|
||||
return href
|
||||
elif href.startswith('/'):
|
||||
parsed = urlparse(current_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}{href}"
|
||||
else:
|
||||
return urljoin(current_url, href)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Essayer de construire l'URL de la page suivante par pattern
|
||||
# Pattern 1: ?page=X
|
||||
if 'page=' in current_url:
|
||||
return re.sub(r'page=\d+', f'page={current_page + 1}', current_url)
|
||||
|
||||
# Pattern 2: /pageX
|
||||
if f'/page{current_page}' in current_url:
|
||||
return current_url.replace(f'/page{current_page}', f'/page{current_page + 1}')
|
||||
|
||||
# Pattern 3: Ajouter ?page=2 si c'est la première page
|
||||
if current_page == 1:
|
||||
separator = '&' if '?' in current_url else '?'
|
||||
return f"{current_url}{separator}page={current_page + 1}"
|
||||
|
||||
return None
|
||||
|
||||
def _extract_contact_info(self, soup: BeautifulSoup, text: str, page_url: str) -> List[Dict]:
|
||||
"""
|
||||
Extraire les informations de contact complètes d'une page
|
||||
"""
|
||||
contacts = []
|
||||
|
||||
# Extraire tous les emails
|
||||
emails = set()
|
||||
emails.update(self._extract_emails_from_text(text))
|
||||
emails.update(self._extract_emails_from_links(soup))
|
||||
|
||||
# Extraire les numéros de téléphone
|
||||
phones = self._extract_phone_numbers(text)
|
||||
|
||||
# Extraire les noms et entreprises depuis les balises structurées
|
||||
structured_contacts = self._extract_structured_contacts(soup)
|
||||
|
||||
# Extraire l'adresse/localité
|
||||
location = self._extract_location_info(soup, text)
|
||||
|
||||
# Créer des contacts pour chaque email trouvé
|
||||
for email in emails:
|
||||
if not self._is_valid_email(email):
|
||||
continue
|
||||
|
||||
contact = {
|
||||
'email': email.lower(),
|
||||
'name': '',
|
||||
'first_name': '',
|
||||
'last_name': '',
|
||||
'company': '',
|
||||
'phone': '',
|
||||
'location': location,
|
||||
'source_url': page_url,
|
||||
'notes': ''
|
||||
}
|
||||
|
||||
# Essayer de trouver des informations complémentaires
|
||||
self._enhance_contact_info(contact, soup, text, structured_contacts, phones)
|
||||
|
||||
contacts.append(contact)
|
||||
|
||||
return contacts
|
||||
|
||||
def _extract_phone_numbers(self, text: str) -> List[str]:
|
||||
"""
|
||||
Extraire les numéros de téléphone
|
||||
"""
|
||||
phones = []
|
||||
matches = self.phone_pattern.findall(text)
|
||||
|
||||
for phone in matches:
|
||||
# Nettoyer le numéro
|
||||
clean_phone = re.sub(r'[\s\-\.\/]', '', phone)
|
||||
if len(clean_phone) >= 9: # Numéro valide
|
||||
phones.append(phone)
|
||||
|
||||
return phones
|
||||
|
||||
def _extract_structured_contacts(self, soup: BeautifulSoup) -> List[Dict]:
|
||||
"""
|
||||
Extraire les contacts depuis les données structurées (microdata, JSON-LD, etc.)
|
||||
"""
|
||||
contacts = []
|
||||
|
||||
# Chercher les données JSON-LD
|
||||
json_scripts = soup.find_all('script', type='application/ld+json')
|
||||
for script in json_scripts:
|
||||
try:
|
||||
data = json.loads(script.string)
|
||||
if isinstance(data, dict):
|
||||
contact = self._parse_json_ld_contact(data)
|
||||
if contact:
|
||||
contacts.append(contact)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
contact = self._parse_json_ld_contact(item)
|
||||
if contact:
|
||||
contacts.append(contact)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Chercher les microdata
|
||||
contacts.extend(self._extract_microdata_contacts(soup))
|
||||
|
||||
return contacts
|
||||
|
||||
def _parse_json_ld_contact(self, data: Dict) -> Dict:
|
||||
"""
|
||||
Parser un contact depuis les données JSON-LD
|
||||
"""
|
||||
contact = {}
|
||||
|
||||
if data.get('@type') in ['Organization', 'LocalBusiness', 'Person']:
|
||||
contact['name'] = data.get('name', '')
|
||||
contact['company'] = data.get('name', '') if data.get('@type') != 'Person' else ''
|
||||
|
||||
# Email
|
||||
email = data.get('email')
|
||||
if email:
|
||||
contact['email'] = email
|
||||
|
||||
# Téléphone
|
||||
phone = data.get('telephone')
|
||||
if phone:
|
||||
contact['phone'] = phone
|
||||
|
||||
# Adresse
|
||||
address = data.get('address')
|
||||
if address:
|
||||
if isinstance(address, dict):
|
||||
location_parts = []
|
||||
if address.get('addressLocality'):
|
||||
location_parts.append(address['addressLocality'])
|
||||
if address.get('addressRegion'):
|
||||
location_parts.append(address['addressRegion'])
|
||||
if address.get('addressCountry'):
|
||||
location_parts.append(address['addressCountry'])
|
||||
contact['location'] = ', '.join(location_parts)
|
||||
elif isinstance(address, str):
|
||||
contact['location'] = address
|
||||
|
||||
return contact if contact.get('email') or contact.get('name') else None
|
||||
|
||||
def _extract_microdata_contacts(self, soup: BeautifulSoup) -> List[Dict]:
|
||||
"""
|
||||
Extraire les contacts depuis les microdata
|
||||
"""
|
||||
contacts = []
|
||||
|
||||
# Chercher les éléments avec itemtype Person ou Organization
|
||||
items = soup.find_all(attrs={'itemtype': re.compile(r'.*(Person|Organization|LocalBusiness).*')})
|
||||
|
||||
for item in items:
|
||||
contact = {}
|
||||
|
||||
# Nom
|
||||
name_elem = item.find(attrs={'itemprop': 'name'})
|
||||
if name_elem:
|
||||
contact['name'] = name_elem.get_text(strip=True)
|
||||
|
||||
# Email
|
||||
email_elem = item.find(attrs={'itemprop': 'email'})
|
||||
if email_elem:
|
||||
contact['email'] = email_elem.get('href', '').replace('mailto:', '') or email_elem.get_text(strip=True)
|
||||
|
||||
# Téléphone
|
||||
phone_elem = item.find(attrs={'itemprop': 'telephone'})
|
||||
if phone_elem:
|
||||
contact['phone'] = phone_elem.get_text(strip=True)
|
||||
|
||||
if contact.get('email') or contact.get('name'):
|
||||
contacts.append(contact)
|
||||
|
||||
return contacts
|
||||
|
||||
def _extract_location_info(self, soup: BeautifulSoup, text: str) -> str:
|
||||
"""
|
||||
Extraire les informations de localisation
|
||||
"""
|
||||
location_indicators = [
|
||||
r'\b\d{4,5}\s+[A-Za-zÀ-ÿ\s\-]+\b', # Code postal + ville
|
||||
r'\b[A-Za-zÀ-ÿ\s\-]+,\s*[A-Za-zÀ-ÿ\s\-]+\b', # Ville, Pays
|
||||
]
|
||||
|
||||
# Chercher dans les balises d'adresse
|
||||
address_tags = soup.find_all(['address', 'div'], class_=re.compile(r'.*address.*|.*location.*|.*contact.*'))
|
||||
for tag in address_tags:
|
||||
address_text = tag.get_text(strip=True)
|
||||
for pattern in location_indicators:
|
||||
match = re.search(pattern, address_text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
# Chercher dans le texte global
|
||||
for pattern in location_indicators:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
return ''
|
||||
|
||||
def _enhance_contact_info(self, contact: Dict, soup: BeautifulSoup, text: str, structured_contacts: List[Dict], phones: List[str]):
|
||||
"""
|
||||
Améliorer les informations de contact en croisant les données
|
||||
"""
|
||||
email = contact['email']
|
||||
|
||||
# Chercher dans les contacts structurés
|
||||
for struct_contact in structured_contacts:
|
||||
if struct_contact.get('email') == email:
|
||||
contact.update(struct_contact)
|
||||
break
|
||||
|
||||
# Si pas de nom trouvé, essayer d'extraire depuis l'email
|
||||
if not contact['name']:
|
||||
local_part = email.split('@')[0]
|
||||
domain_part = email.split('@')[1]
|
||||
|
||||
# Essayer de deviner le nom depuis la partie locale
|
||||
if '.' in local_part:
|
||||
parts = local_part.split('.')
|
||||
contact['first_name'] = parts[0].title()
|
||||
contact['last_name'] = parts[1].title() if len(parts) > 1 else ''
|
||||
contact['name'] = f"{contact['first_name']} {contact['last_name']}".strip()
|
||||
else:
|
||||
contact['name'] = local_part.title()
|
||||
|
||||
# Essayer de deviner l'entreprise depuis le domaine
|
||||
if not contact['company']:
|
||||
company_name = domain_part.split('.')[0]
|
||||
contact['company'] = company_name.title()
|
||||
|
||||
# Ajouter un numéro de téléphone si disponible
|
||||
if not contact['phone'] and phones:
|
||||
contact['phone'] = phones[0] # Prendre le premier numéro trouvé
|
||||
|
||||
# Enrichir les notes avec des informations contextuelles
|
||||
notes_parts = []
|
||||
if contact['location']:
|
||||
notes_parts.append(f"Localisation: {contact['location']}")
|
||||
|
||||
# Chercher des informations sur la fonction/titre
|
||||
title_patterns = [
|
||||
r'(?i)(?:directeur|manager|responsable|chef|président|ceo|cto|cfo)\s+[a-zA-ZÀ-ÿ\s]+',
|
||||
r'(?i)[a-zA-ZÀ-ÿ\s]+\s+(?:director|manager|head|chief|president)'
|
||||
]
|
||||
|
||||
for pattern in title_patterns:
|
||||
matches = re.findall(pattern, text)
|
||||
if matches:
|
||||
notes_parts.append(f"Fonction possible: {matches[0]}")
|
||||
break
|
||||
|
||||
contact['notes'] = ' | '.join(notes_parts)
|
||||
|
||||
def _merge_contact_info(self, existing: Dict, new: Dict):
|
||||
"""
|
||||
Fusionner les informations de deux contacts
|
||||
"""
|
||||
for key, value in new.items():
|
||||
if value and not existing.get(key):
|
||||
existing[key] = value
|
||||
|
||||
# Fusionner les notes
|
||||
if new.get('notes') and existing.get('notes'):
|
||||
existing['notes'] = f"{existing['notes']} | {new['notes']}"
|
||||
elif new.get('notes'):
|
||||
existing['notes'] = new['notes']
|
||||
|
||||
def _extract_domain_info(self, url: str, results: Dict):
|
||||
"""
|
||||
Extraire les informations générales du domaine
|
||||
"""
|
||||
domain = urlparse(url).netloc
|
||||
|
||||
results['domain_info'] = {
|
||||
'domain': domain,
|
||||
'company_guess': domain.split('.')[0].title(),
|
||||
'total_contacts': len(results['contacts']),
|
||||
'total_pages_scraped': len(results['pages_scraped'])
|
||||
}
|
||||
|
||||
def _extract_emails_from_links(self, soup: BeautifulSoup) -> Set[str]:
|
||||
"""
|
||||
Extraire les emails des liens mailto
|
||||
"""
|
||||
emails = set()
|
||||
|
||||
# Chercher les liens mailto
|
||||
mailto_links = soup.find_all('a', href=re.compile(r'^mailto:', re.I))
|
||||
for link in mailto_links:
|
||||
href = link.get('href', '')
|
||||
email_match = re.search(r'mailto:([^?&]+)', href, re.I)
|
||||
if email_match:
|
||||
email = email_match.group(1)
|
||||
if self._is_valid_email(email):
|
||||
emails.add(email.lower())
|
||||
|
||||
return emails
|
||||
|
||||
def _extract_emails_from_text(self, text: str) -> Set[str]:
|
||||
"""
|
||||
Extraire les emails du texte de la page
|
||||
"""
|
||||
emails = set()
|
||||
matches = self.email_pattern.findall(text)
|
||||
|
||||
for email in matches:
|
||||
# Filtrer les emails indésirables
|
||||
if not self._is_valid_email(email):
|
||||
continue
|
||||
emails.add(email.lower())
|
||||
|
||||
return emails
|
||||
|
||||
def _extract_internal_links(self, soup: BeautifulSoup, base_url: str) -> List[str]:
|
||||
"""
|
||||
Extraire les liens internes de la page
|
||||
"""
|
||||
links = []
|
||||
base_domain = urlparse(base_url).netloc
|
||||
|
||||
for link in soup.find_all('a', href=True):
|
||||
href = link['href']
|
||||
full_url = urljoin(base_url, href)
|
||||
parsed_link = urlparse(full_url)
|
||||
|
||||
# Vérifier que c'est un lien interne et pas déjà visité
|
||||
if (parsed_link.netloc == base_domain and
|
||||
full_url not in self.visited_urls and
|
||||
not self._is_excluded_link(full_url)):
|
||||
links.append(full_url)
|
||||
|
||||
return links
|
||||
|
||||
def _is_valid_email(self, email: str) -> bool:
|
||||
"""
|
||||
Vérifier si l'email est valide et non indésirable
|
||||
"""
|
||||
# Filtrer les extensions de fichiers communes
|
||||
excluded_extensions = ['.jpg', '.png', '.gif', '.pdf', '.doc', '.css', '.js']
|
||||
|
||||
for ext in excluded_extensions:
|
||||
if email.lower().endswith(ext):
|
||||
return False
|
||||
|
||||
# Filtrer les emails génériques indésirables
|
||||
excluded_patterns = [
|
||||
'example.com',
|
||||
'test.com',
|
||||
'placeholder',
|
||||
'your-email',
|
||||
'youremail',
|
||||
'email@',
|
||||
'noreply',
|
||||
'no-reply'
|
||||
]
|
||||
|
||||
for pattern in excluded_patterns:
|
||||
if pattern in email.lower():
|
||||
return False
|
||||
|
||||
# Vérifier la longueur
|
||||
if len(email) < 5 or len(email) > 254:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _is_excluded_link(self, url: str) -> bool:
|
||||
"""
|
||||
Vérifier si le lien doit être exclu du scraping
|
||||
"""
|
||||
excluded_patterns = [
|
||||
'#',
|
||||
'javascript:',
|
||||
'tel:',
|
||||
'mailto:',
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.zip',
|
||||
'.jpg',
|
||||
'.png',
|
||||
'.gif'
|
||||
]
|
||||
|
||||
url_lower = url.lower()
|
||||
for pattern in excluded_patterns:
|
||||
if pattern in url_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def save_results(self, results: Dict, filename: str = None) -> str:
|
||||
"""
|
||||
Sauvegarder les résultats dans un fichier JSON
|
||||
"""
|
||||
if not filename:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
domain = urlparse(results['url']).netloc.replace('.', '_')
|
||||
filename = f"scraping_{domain}_{timestamp}.json"
|
||||
|
||||
# Créer le dossier s'il n'existe pas
|
||||
scraping_folder = 'Data/email_scraping'
|
||||
os.makedirs(scraping_folder, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(scraping_folder, filename)
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return filepath
|
||||
|
||||
class EmailScrapingHistory:
|
||||
def __init__(self):
|
||||
self.history_folder = 'Data/email_scraping'
|
||||
os.makedirs(self.history_folder, exist_ok=True)
|
||||
|
||||
def get_all_scrapings(self) -> List[Dict]:
|
||||
"""
|
||||
Récupérer l'historique de tous les scrapings
|
||||
"""
|
||||
scrapings = []
|
||||
|
||||
for filename in os.listdir(self.history_folder):
|
||||
if filename.endswith('.json'):
|
||||
filepath = os.path.join(self.history_folder, filename)
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
scrapings.append({
|
||||
'filename': filename,
|
||||
'url': data.get('url', ''),
|
||||
'emails_count': len(data.get('contacts', data.get('emails', []))), # Support pour ancienne et nouvelle structure
|
||||
'pages_count': len(data.get('pages_scraped', [])),
|
||||
'start_time': data.get('start_time', ''),
|
||||
'errors_count': len(data.get('errors', []))
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de la lecture de {filename}: {e}")
|
||||
|
||||
# Trier par date (plus récent d'abord)
|
||||
scrapings.sort(key=lambda x: x.get('start_time', ''), reverse=True)
|
||||
|
||||
return scrapings
|
||||
|
||||
def get_scraping_details(self, filename: str) -> Dict:
|
||||
"""
|
||||
Récupérer les détails d'un scraping spécifique
|
||||
"""
|
||||
filepath = os.path.join(self.history_folder, filename)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
return None
|
||||
|
||||
def delete_scraping(self, filename: str) -> bool:
|
||||
"""
|
||||
Supprimer un fichier de scraping
|
||||
"""
|
||||
filepath = os.path.join(self.history_folder, filename)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de la suppression: {e}")
|
||||
return False
|
||||
|
||||
return False
|
||||
67
modules/projects/project.py
Normal file
67
modules/projects/project.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
value = re.sub(r'[^a-z0-9\-_\s]+', '', value)
|
||||
value = re.sub(r'[\s]+', '-', value)
|
||||
return value
|
||||
|
||||
|
||||
class Project:
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
name: str,
|
||||
status: str = 'Nouveau',
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
description: str = '',
|
||||
budget: Optional[float] = None,
|
||||
id: Optional[str] = None,
|
||||
created_at: Optional[str] = None,
|
||||
updated_at: Optional[str] = None
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.name = name
|
||||
self.status = status
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
self.description = description
|
||||
self.budget = budget
|
||||
self.id = id or f"{_slugify(name)}-{uuid.uuid4().hex[:8]}"
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
self.created_at = created_at or now_iso
|
||||
self.updated_at = updated_at or now_iso
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"client_id": self.client_id,
|
||||
"name": self.name,
|
||||
"status": self.status,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
"description": self.description,
|
||||
"budget": self.budget,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> 'Project':
|
||||
return Project(
|
||||
client_id=data.get("client_id"),
|
||||
name=data.get("name", ""),
|
||||
status=data.get("status", "Nouveau"),
|
||||
start_date=data.get("start_date"),
|
||||
end_date=data.get("end_date"),
|
||||
description=data.get("description", ""),
|
||||
budget=data.get("budget"),
|
||||
id=data.get("id"),
|
||||
created_at=data.get("created_at"),
|
||||
updated_at=data.get("updated_at"),
|
||||
)
|
||||
98
modules/projects/project_handler.py
Normal file
98
modules/projects/project_handler.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import json
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from modules.projects.project import Project
|
||||
|
||||
|
||||
class ProjectHandler:
|
||||
def __init__(self, base_dir: str = None):
|
||||
# Par défaut, stocker sous Data/projects
|
||||
self.base_dir = base_dir or os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'Data', 'projects')
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
||||
def _client_dir(self, client_id: str) -> str:
|
||||
path = os.path.join(self.base_dir, client_id)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
def _project_path(self, client_id: str, project_id: str) -> str:
|
||||
return os.path.join(self._client_dir(client_id), f"{project_id}.json")
|
||||
|
||||
def list_projects(self, client_id: str) -> List[Project]:
|
||||
projects: List[Project] = []
|
||||
directory = self._client_dir(client_id)
|
||||
if not os.path.isdir(directory):
|
||||
return projects
|
||||
for filename in os.listdir(directory):
|
||||
if filename.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(directory, filename), 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
projects.append(Project.from_dict(data))
|
||||
except Exception:
|
||||
# Ignorer fichiers corrompus
|
||||
continue
|
||||
# Tri par date de mise à jour décroissante
|
||||
projects.sort(key=lambda p: p.updated_at or "", reverse=True)
|
||||
return projects
|
||||
|
||||
def get_project(self, client_id: str, project_id: str) -> Optional[Project]:
|
||||
path = self._project_path(client_id, project_id)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return Project.from_dict(data)
|
||||
|
||||
def add_project(self, project: Project) -> str:
|
||||
path = self._project_path(project.client_id, project.id)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
return project.id
|
||||
|
||||
def update_project(self, project: Project) -> bool:
|
||||
path = self._project_path(project.client_id, project.id)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
# mettre à jour updated_at
|
||||
from datetime import datetime
|
||||
project.updated_at = datetime.utcnow().isoformat()
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
|
||||
def delete_project(self, client_id: str, project_id: str) -> bool:
|
||||
path = self._project_path(client_id, project_id)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def add_task_for_project(self, project_id: str, title: str, due_date: str, description: str = "", priority: str = "normale", metadata: dict = None):
|
||||
"""
|
||||
Crée une tâche liée à un projet.
|
||||
:param project_id: ID du projet
|
||||
:param title: Titre de la tâche
|
||||
:param due_date: Date d'échéance au format ISO (YYYY-MM-DD)
|
||||
:param description: Description optionnelle
|
||||
:param priority: 'basse' | 'normale' | 'haute'
|
||||
:param metadata: métadonnées optionnelles
|
||||
:return: ID de la tâche créée
|
||||
"""
|
||||
from modules.tasks.task import Task
|
||||
from modules.tasks.task_handler import TaskHandler
|
||||
|
||||
task = Task(
|
||||
title=title,
|
||||
due_date=due_date,
|
||||
description=description,
|
||||
entity_type="project",
|
||||
entity_id=project_id,
|
||||
priority=priority,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return TaskHandler().add_task(task)
|
||||
111
modules/projects/routes.py
Normal file
111
modules/projects/routes.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import os
|
||||
import json
|
||||
from typing import Dict, List
|
||||
from flask import Blueprint, render_template, request
|
||||
|
||||
from modules.projects.project_handler import ProjectHandler
|
||||
|
||||
projects_bp = Blueprint('projects', __name__)
|
||||
_handler = ProjectHandler()
|
||||
|
||||
def _project_root() -> str:
|
||||
# 3 niveaux au dessus de modules/projects/ = racine projet
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
def _clients_dir() -> str:
|
||||
return os.path.join(_project_root(), 'Data', 'clients')
|
||||
|
||||
def _load_clients_index() -> Dict[str, str]:
|
||||
"""
|
||||
Retourne un dict {client_id: client_name lisible}
|
||||
client_id = nom de fichier sans extension dans Data/clients
|
||||
"""
|
||||
idx: Dict[str, str] = {}
|
||||
cdir = _clients_dir()
|
||||
if not os.path.isdir(cdir):
|
||||
return idx
|
||||
for fname in os.listdir(cdir):
|
||||
if not fname.endswith('.json'):
|
||||
continue
|
||||
client_id = os.path.splitext(fname)[0]
|
||||
path = os.path.join(cdir, fname)
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
name = data.get('client_name') or client_id.replace('_', ' ').title()
|
||||
idx[client_id] = name
|
||||
except Exception:
|
||||
idx[client_id] = client_id.replace('_', ' ').title()
|
||||
return idx
|
||||
|
||||
def _list_all_projects() -> List:
|
||||
"""
|
||||
Parcourt Data/projects/<client_id> et retourne la liste des objets Project.
|
||||
"""
|
||||
base = _handler.base_dir
|
||||
projects = []
|
||||
if os.path.isdir(base):
|
||||
for client_id in os.listdir(base):
|
||||
client_path = os.path.join(base, client_id)
|
||||
if os.path.isdir(client_path):
|
||||
projects.extend(_handler.list_projects(client_id))
|
||||
return projects
|
||||
|
||||
@projects_bp.route('/projects', methods=['GET'])
|
||||
def projects_index():
|
||||
clients_idx = _load_clients_index()
|
||||
selected_client = request.args.get('client_id', '').strip()
|
||||
query = request.args.get('q', '').strip().lower()
|
||||
|
||||
# Charger tous les projets
|
||||
projects = _list_all_projects()
|
||||
|
||||
# Filtre par client
|
||||
if selected_client:
|
||||
projects = [p for p in projects if p.client_id == selected_client]
|
||||
|
||||
# Recherche plein texte basique
|
||||
if query:
|
||||
def match(p) -> bool:
|
||||
hay = ' '.join([
|
||||
p.name or '',
|
||||
p.status or '',
|
||||
p.description or ''
|
||||
]).lower()
|
||||
return query in hay
|
||||
projects = [p for p in projects if match(p)]
|
||||
|
||||
# Tri par updated_at décroissante
|
||||
projects.sort(key=lambda p: p.updated_at or '', reverse=True)
|
||||
|
||||
# Transformer pour la vue (nom client)
|
||||
def client_name_for(pid: str) -> str:
|
||||
return clients_idx.get(pid, pid.replace('_', ' ').title())
|
||||
|
||||
view_projects = []
|
||||
for p in projects:
|
||||
view_projects.append({
|
||||
'id': p.id,
|
||||
'client_id': p.client_id,
|
||||
'client_name': client_name_for(p.client_id),
|
||||
'name': p.name,
|
||||
'status': p.status,
|
||||
'start_date': p.start_date,
|
||||
'end_date': p.end_date,
|
||||
'budget': p.budget,
|
||||
'updated_at': p.updated_at
|
||||
})
|
||||
|
||||
# Liste triée des clients pour le sélecteur
|
||||
clients_for_select = sorted(
|
||||
[{'id': cid, 'name': cname} for cid, cname in clients_idx.items()],
|
||||
key=lambda c: c['name'].lower()
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'projects/all_projects.html',
|
||||
projects=view_projects,
|
||||
clients=clients_for_select,
|
||||
selected_client=selected_client,
|
||||
q=request.args.get('q', '').strip()
|
||||
)
|
||||
27
modules/proposition/app.py
Normal file
27
modules/proposition/app.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Importation des modules nécessaires
|
||||
from modules.proposition.fields import fields
|
||||
from core.form import Form
|
||||
from core.generator import Generator
|
||||
from core.data import Data
|
||||
|
||||
def main():
|
||||
print("=== Générateur de proposition commerciale ===\n")
|
||||
|
||||
form = Form(fields())
|
||||
form.ask()
|
||||
data = form.get_data()
|
||||
|
||||
# Transformer les fonctionnalités en une liste de dictionnaires
|
||||
features = data.get("features", "").split(",")
|
||||
data["features"] = [{"description": feature.strip()} for feature in features if feature.strip()]
|
||||
|
||||
client_name = data.get("client_name", "").replace(" ", "_").lower()
|
||||
|
||||
data_manager = Data(f"Data/clients/{client_name}.json")
|
||||
client_data = data_manager.save_data(data)
|
||||
print("\n✅ Données du client enregistrées avec succès.")
|
||||
|
||||
generator = Generator(data)
|
||||
content = generator.generate_pdf("propositions")
|
||||
|
||||
print("\n✅ Proposition générée avec succès.")
|
||||
16
modules/proposition/fields.py
Normal file
16
modules/proposition/fields.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
def fields() -> list[dict]:
|
||||
return [
|
||||
{"name": "client_name", "label": "Nom du client", "type": "text", "required": True},
|
||||
{"name": "email", "label": "Email", "type": "email"},
|
||||
{"name": "telephone", "label": "Téléphone", "type": "tel"},
|
||||
{"name": "adresse", "label": "Adresse", "type": "text"},
|
||||
{"name": "project_name", "label": "Nom du projet", "type": "text", "required": True},
|
||||
{"name": "project_type", "label": "Type de projet", "type": "text"},
|
||||
{"name": "deadline", "label": "Délai de livraison", "type": "date"},
|
||||
{"name": "project_description", "label": "Description du projet", "type": "textarea"},
|
||||
{"name": "features", "label": "Fonctionnalités principales (séparées par des virgules)", "type": "textarea"},
|
||||
{"name": "budget", "label": "Budget estimé", "type": "text"},
|
||||
{"name": "payment_terms", "label": "Conditions de paiement", "type": "text"},
|
||||
{"name": "contact_info", "label": "Coordonnées", "type": "text"},
|
||||
{"name": "additional_info", "label": "Informations complémentaires", "type": "textarea"}
|
||||
]
|
||||
4
modules/tasks/__init__.py
Normal file
4
modules/tasks/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .task import Task
|
||||
from .task_handler import TaskHandler
|
||||
|
||||
__all__ = ["Task", "TaskHandler"]
|
||||
64
modules/tasks/task.py
Normal file
64
modules/tasks/task.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional, Literal, Dict, Any
|
||||
import uuid
|
||||
|
||||
|
||||
class Task:
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
due_date: str,
|
||||
description: str = "",
|
||||
entity_type: Optional[Literal["client", "prospect", "project", "campaign"]] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
priority: Literal["basse", "normale", "haute"] = "normale",
|
||||
status: Literal["todo", "done", "canceled"] = "todo",
|
||||
id: Optional[str] = None,
|
||||
created_at: Optional[str] = None,
|
||||
completed_at: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.id = id or f"tsk_{uuid.uuid4().hex[:8]}"
|
||||
self.title = title
|
||||
self.description = description
|
||||
# due_date: format ISO "YYYY-MM-DD"
|
||||
self.due_date = due_date
|
||||
self.entity_type = entity_type
|
||||
self.entity_id = entity_id
|
||||
self.priority = priority
|
||||
self.status = status
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
self.created_at = created_at or now_iso
|
||||
self.completed_at = completed_at
|
||||
self.metadata = metadata or {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"due_date": self.due_date,
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": self.entity_id,
|
||||
"priority": self.priority,
|
||||
"status": self.status,
|
||||
"created_at": self.created_at,
|
||||
"completed_at": self.completed_at,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "Task":
|
||||
return Task(
|
||||
id=data.get("id"),
|
||||
title=data.get("title", ""),
|
||||
description=data.get("description", ""),
|
||||
due_date=data.get("due_date", ""),
|
||||
entity_type=data.get("entity_type"),
|
||||
entity_id=data.get("entity_id"),
|
||||
priority=data.get("priority", "normale"),
|
||||
status=data.get("status", "todo"),
|
||||
created_at=data.get("created_at"),
|
||||
completed_at=data.get("completed_at"),
|
||||
metadata=data.get("metadata") or {},
|
||||
)
|
||||
207
modules/tasks/task_handler.py
Normal file
207
modules/tasks/task_handler.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Optional, Literal
|
||||
|
||||
from modules.tasks.task import Task
|
||||
|
||||
|
||||
class TaskHandler:
|
||||
def __init__(self, base_dir: Optional[str] = None):
|
||||
# Par défaut: Data/tasks (un fichier JSON par tâche)
|
||||
self.base_dir = base_dir or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"Data",
|
||||
"tasks",
|
||||
)
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
||||
def _task_path(self, task_id: str) -> str:
|
||||
return os.path.join(self.base_dir, f"{task_id}.json")
|
||||
|
||||
def add_task(self, task: Task) -> str:
|
||||
path = self._task_path(task.id)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(task.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
return task.id
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[Task]:
|
||||
path = self._task_path(task_id)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return Task.from_dict(data)
|
||||
|
||||
def update_task(self, task: Task) -> bool:
|
||||
path = self._task_path(task.id)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
# pas de gestion updated_at pour rester simple
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(task.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
path = self._task_path(task_id)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def list_tasks(self, status: Optional[Literal["todo", "done", "canceled"]] = None) -> List[Task]:
|
||||
tasks: List[Task] = []
|
||||
for filename in os.listdir(self.base_dir):
|
||||
if filename.endswith(".json"):
|
||||
try:
|
||||
with open(os.path.join(self.base_dir, filename), "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
t = Task.from_dict(data)
|
||||
if status is None or t.status == status:
|
||||
tasks.append(t)
|
||||
except Exception:
|
||||
continue
|
||||
# Tri par (due_date, priorité, création)
|
||||
def _prio_val(p: str) -> int:
|
||||
return {"haute": 0, "normale": 1, "basse": 2}.get(p, 1)
|
||||
|
||||
tasks.sort(key=lambda t: (t.due_date or "", _prio_val(t.priority), t.created_at or ""))
|
||||
return tasks
|
||||
|
||||
def list_tasks_for_date(
|
||||
self,
|
||||
day_iso: str,
|
||||
status: Optional[Literal["todo", "done", "canceled"]] = None,
|
||||
) -> List[Task]:
|
||||
return [t for t in self.list_tasks(status=status) if (t.due_date or "") == day_iso]
|
||||
|
||||
def list_today_tasks(self, status: Optional[Literal["todo", "done", "canceled"]] = None) -> List[Task]:
|
||||
return self.list_tasks_for_date(date.today().isoformat(), status=status)
|
||||
|
||||
def list_tomorrow_tasks(self, status: Optional[Literal["todo", "done", "canceled"]] = None) -> List[Task]:
|
||||
tomorrow = (date.today() + timedelta(days=1)).isoformat()
|
||||
return self.list_tasks_for_date(tomorrow, status=status)
|
||||
|
||||
def list_overdue_tasks(self) -> List[Task]:
|
||||
"""Retourne les tâches en retard (due_date < aujourd'hui) encore à faire."""
|
||||
today_iso = date.today().isoformat()
|
||||
tasks = self.list_tasks(status="todo")
|
||||
return [t for t in tasks if (t.due_date or "") < today_iso]
|
||||
|
||||
def list_tasks_by_entity(
|
||||
self,
|
||||
entity_type: Literal["client", "prospect", "project", "campaign"],
|
||||
entity_id: str,
|
||||
status: Optional[Literal["todo", "done", "canceled"]] = None,
|
||||
) -> List[Task]:
|
||||
return [
|
||||
t
|
||||
for t in self.list_tasks(status=status)
|
||||
if t.entity_type == entity_type and t.entity_id == entity_id
|
||||
]
|
||||
|
||||
def complete_task(self, task_id: str) -> bool:
|
||||
t = self.get_task(task_id)
|
||||
if not t:
|
||||
return False
|
||||
t.status = "done"
|
||||
t.completed_at = datetime.utcnow().isoformat()
|
||||
return self.update_task(t)
|
||||
|
||||
def cancel_task(self, task_id: str) -> bool:
|
||||
t = self.get_task(task_id)
|
||||
if not t:
|
||||
return False
|
||||
t.status = "canceled"
|
||||
return self.update_task(t)
|
||||
|
||||
def postpone_task(self, task_id: str, new_due_date: str) -> bool:
|
||||
t = self.get_task(task_id)
|
||||
if not t:
|
||||
return False
|
||||
t.due_date = new_due_date
|
||||
return self.update_task(t)
|
||||
|
||||
# Raccourcis pour créer des tâches liées
|
||||
def add_task_for_client(
|
||||
self,
|
||||
client_id: str,
|
||||
title: str,
|
||||
due_date: str,
|
||||
description: str = "",
|
||||
priority: Literal["basse", "normale", "haute"] = "normale",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> str:
|
||||
task = Task(
|
||||
title=title,
|
||||
description=description,
|
||||
due_date=due_date,
|
||||
entity_type="client",
|
||||
entity_id=client_id,
|
||||
priority=priority,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return self.add_task(task)
|
||||
|
||||
def add_task_for_prospect(
|
||||
self,
|
||||
prospect_id: str,
|
||||
title: str,
|
||||
due_date: str,
|
||||
description: str = "",
|
||||
priority: Literal["basse", "normale", "haute"] = "normale",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> str:
|
||||
task = Task(
|
||||
title=title,
|
||||
description=description,
|
||||
due_date=due_date,
|
||||
entity_type="prospect",
|
||||
entity_id=prospect_id,
|
||||
priority=priority,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return self.add_task(task)
|
||||
|
||||
def add_task_for_project(
|
||||
self,
|
||||
project_id: str,
|
||||
title: str,
|
||||
due_date: str,
|
||||
description: str = "",
|
||||
priority: Literal["basse", "normale", "haute"] = "normale",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> str:
|
||||
task = Task(
|
||||
title=title,
|
||||
description=description,
|
||||
due_date=due_date,
|
||||
entity_type="project",
|
||||
entity_id=project_id,
|
||||
priority=priority,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return self.add_task(task)
|
||||
|
||||
def add_task_for_campaign(
|
||||
self,
|
||||
campaign_id: str,
|
||||
title: str,
|
||||
due_date: str,
|
||||
description: str = "",
|
||||
priority: Literal["basse", "normale", "haute"] = "normale",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> str:
|
||||
task = Task(
|
||||
title=title,
|
||||
description=description,
|
||||
due_date=due_date,
|
||||
entity_type="campaign",
|
||||
entity_id=campaign_id,
|
||||
priority=priority,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return self.add_task(task)
|
||||
275
modules/tasks/web.py
Normal file
275
modules/tasks/web.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from datetime import date, datetime
|
||||
from modules.tasks.task_handler import TaskHandler
|
||||
from modules.tasks.task import Task
|
||||
from modules.email.draft_handler import DraftHandler
|
||||
from modules.email.email_manager import EmailSender
|
||||
from jobs.daily_reminder_job import main as generate_email_drafts_today
|
||||
from html import escape
|
||||
|
||||
tasks_bp = Blueprint("tasks", __name__, url_prefix="/tasks")
|
||||
|
||||
|
||||
@tasks_bp.get("/")
|
||||
def tasks_index():
|
||||
"""
|
||||
Page de gestion des tâches: liste + formulaire de création.
|
||||
Filtres optionnels via query string: status, entity_type, entity_id
|
||||
"""
|
||||
handler = TaskHandler()
|
||||
status = request.args.get("status")
|
||||
entity_type = request.args.get("entity_type")
|
||||
entity_id = request.args.get("entity_id")
|
||||
|
||||
tasks = handler.list_tasks(status=status) if status else handler.list_tasks()
|
||||
if entity_type and entity_id:
|
||||
tasks = [t for t in tasks if t.entity_type == entity_type and t.entity_id == entity_id]
|
||||
|
||||
return render_template(
|
||||
"tasks/manage.html",
|
||||
tasks=tasks,
|
||||
status=status,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
today=date.today().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@tasks_bp.get("/today/fragment")
|
||||
def today_fragment():
|
||||
"""
|
||||
Renvoie le fragment HTML du bloc 'tâches du jour'
|
||||
"""
|
||||
handler = TaskHandler()
|
||||
today_tasks = handler.list_today_tasks(status="todo")
|
||||
return render_template("partials/tasks_today_block.html", today_tasks=today_tasks)
|
||||
|
||||
@tasks_bp.get('/tomorrow/fragment')
|
||||
def tomorrow_fragment():
|
||||
"""
|
||||
Renvoie le fragment HTML du bloc 'tâches du lendemain'
|
||||
"""
|
||||
handler = TaskHandler()
|
||||
tomorrow_tasks = handler.list_tomorrow_tasks(status="todo")
|
||||
return render_template("partials/tasks_today_block.html", today_tasks=tomorrow_tasks)
|
||||
|
||||
@tasks_bp.get("/today/count")
|
||||
def today_count():
|
||||
"""
|
||||
Renvoie le nombre de tâches du jour (todo) au format JSON, pour affichage dans un badge.
|
||||
"""
|
||||
handler = TaskHandler()
|
||||
count = len(handler.list_today_tasks(status="todo"))
|
||||
return jsonify({"count": count})
|
||||
|
||||
|
||||
@tasks_bp.post("/create")
|
||||
def create_task():
|
||||
"""
|
||||
Création d'une tâche depuis un formulaire.
|
||||
Champs attendus: title, due_date (YYYY-MM-DD), description, priority, entity_type, entity_id
|
||||
"""
|
||||
title = (request.form.get("title") or "").strip()
|
||||
due_date = (request.form.get("due_date") or "").strip()
|
||||
description = (request.form.get("description") or "").strip()
|
||||
priority = (request.form.get("priority") or "normale").strip()
|
||||
entity_type = (request.form.get("entity_type") or "").strip() or None
|
||||
entity_id = (request.form.get("entity_id") or "").strip() or None
|
||||
|
||||
if not title or not due_date:
|
||||
flash("Titre et échéance sont obligatoires.", "warning")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
handler = TaskHandler()
|
||||
task = Task(
|
||||
title=title,
|
||||
due_date=due_date,
|
||||
description=description,
|
||||
priority=priority,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
)
|
||||
handler.add_task(task)
|
||||
flash("Tâche créée avec succès.", "success")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
|
||||
@tasks_bp.post("/status")
|
||||
def set_status():
|
||||
"""
|
||||
Met à jour le statut d'une tâche (todo|done|canceled).
|
||||
Form data: task_id, status
|
||||
"""
|
||||
task_id = (request.form.get("task_id") or "").strip()
|
||||
status = (request.form.get("status") or "todo").strip()
|
||||
if not task_id or status not in ("todo", "done", "canceled"):
|
||||
flash("Requête invalide pour le changement de statut.", "warning")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
handler = TaskHandler()
|
||||
task = handler.get_task(task_id)
|
||||
if not task:
|
||||
flash("Tâche introuvable.", "danger")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
task.status = status
|
||||
task.completed_at = datetime.utcnow().isoformat() if status == "done" else None
|
||||
if handler.update_task(task):
|
||||
flash("Statut de la tâche mis à jour.", "success")
|
||||
else:
|
||||
flash("Échec de la mise à jour du statut.", "danger")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
|
||||
@tasks_bp.post("/delete")
|
||||
def delete_task():
|
||||
"""
|
||||
Supprime une tâche.
|
||||
Form data: task_id
|
||||
"""
|
||||
task_id = (request.form.get("task_id") or "").strip()
|
||||
if not task_id:
|
||||
flash("Requête invalide pour la suppression.", "warning")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
handler = TaskHandler()
|
||||
if handler.delete_task(task_id):
|
||||
flash("Tâche supprimée.", "success")
|
||||
else:
|
||||
flash("Échec de la suppression.", "danger")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
|
||||
@tasks_bp.post("/update")
|
||||
def update_task():
|
||||
"""
|
||||
Met à jour les champs d'une tâche.
|
||||
Form data: task_id, title, due_date, description, priority, entity_type, entity_id
|
||||
"""
|
||||
task_id = (request.form.get("task_id") or "").strip()
|
||||
if not task_id:
|
||||
flash("Requête invalide pour l'édition.", "warning")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
handler = TaskHandler()
|
||||
task = handler.get_task(task_id)
|
||||
if not task:
|
||||
flash("Tâche introuvable.", "danger")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
# Mise à jour des champs
|
||||
task.title = (request.form.get("title") or task.title).strip()
|
||||
task.due_date = (request.form.get("due_date") or task.due_date).strip()
|
||||
task.description = (request.form.get("description") or task.description).strip()
|
||||
task.priority = (request.form.get("priority") or task.priority).strip()
|
||||
task.entity_type = (request.form.get("entity_type") or task.entity_type or "").strip() or None
|
||||
task.entity_id = (request.form.get("entity_id") or task.entity_id or "").strip() or None
|
||||
|
||||
if handler.update_task(task):
|
||||
flash("Tâche modifiée.", "success")
|
||||
else:
|
||||
flash("Échec de la modification.", "danger")
|
||||
return redirect(request.referrer or url_for("tasks.tasks_index"))
|
||||
|
||||
|
||||
@tasks_bp.get("/email-drafts")
|
||||
def email_drafts_list():
|
||||
"""
|
||||
Liste des brouillons d'emails à envoyer + actions UI (via template).
|
||||
"""
|
||||
dh = DraftHandler()
|
||||
drafts = dh.list_pending()
|
||||
return render_template("tasks/email_drafts.html", drafts=drafts)
|
||||
|
||||
|
||||
@tasks_bp.post("/email-drafts/generate")
|
||||
def email_drafts_generate():
|
||||
"""
|
||||
Déclenche la génération des brouillons d'aujourd'hui (équivalent au job quotidien).
|
||||
"""
|
||||
try:
|
||||
count = generate_email_drafts_today()
|
||||
if count:
|
||||
flash(f"{count} brouillon(s) généré(s) pour aujourd'hui.", "success")
|
||||
else:
|
||||
flash("Aucun brouillon créé. Vérifiez: tâches 'todo' dues aujourd'hui, liées à des prospects avec un email.", "info")
|
||||
except Exception as e:
|
||||
flash(f"Erreur lors de la génération: {e}", "danger")
|
||||
return redirect(url_for("tasks.email_drafts_list"))
|
||||
|
||||
|
||||
@tasks_bp.post("/email-drafts/send")
|
||||
def email_drafts_send():
|
||||
"""
|
||||
Envoie un brouillon d'email sélectionné puis met à jour son statut.
|
||||
"""
|
||||
draft_id = (request.form.get("draft_id") or "").strip()
|
||||
if not draft_id:
|
||||
flash("Brouillon invalide", "warning")
|
||||
return redirect(url_for("tasks.email_drafts_list"))
|
||||
|
||||
dh = DraftHandler()
|
||||
draft = dh.get_draft(draft_id)
|
||||
if not draft:
|
||||
flash("Brouillon introuvable", "danger")
|
||||
return redirect(url_for("tasks.email_drafts_list"))
|
||||
|
||||
sender = EmailSender()
|
||||
try:
|
||||
res = sender.send_tracked_email(
|
||||
to_email=draft.to_email,
|
||||
subject=draft.subject,
|
||||
body=draft.content,
|
||||
prospect_id=draft.prospect_id,
|
||||
template_id=draft.template_id,
|
||||
)
|
||||
if res.get("success"):
|
||||
dh.mark_sent(draft.id, success=True)
|
||||
flash("Email envoyé.", "success")
|
||||
else:
|
||||
dh.mark_sent(draft.id, success=False, error_message=res.get("error"))
|
||||
flash("Échec de l'envoi de l'email.", "danger")
|
||||
except Exception as e:
|
||||
dh.mark_sent(draft.id, success=False, error_message=str(e))
|
||||
flash("Erreur lors de l'envoi de l'email.", "danger")
|
||||
|
||||
return redirect(url_for("tasks.email_drafts_list"))
|
||||
|
||||
|
||||
@tasks_bp.get("/quick-add")
|
||||
def quick_add_page():
|
||||
"""
|
||||
Page complète 'Quick Add Task' pour une entité donnée.
|
||||
Paramètres query: entity_type (client|prospect|project), entity_id
|
||||
"""
|
||||
entity_type = (request.args.get("entity_type") or "").strip()
|
||||
entity_id = (request.args.get("entity_id") or "").strip()
|
||||
if not entity_type or not entity_id:
|
||||
flash("Paramètres manquants pour lier la tâche à une entité.", "warning")
|
||||
return redirect(url_for("tasks.tasks_index"))
|
||||
|
||||
return render_template(
|
||||
"tasks/quick_add.html",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
today=date.today().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@tasks_bp.get("/quick-add/fragment")
|
||||
def quick_add_fragment():
|
||||
"""
|
||||
Fragment HTML du formulaire 'Quick Add Task' intégré dans d'autres pages.
|
||||
Paramètres query: entity_type, entity_id
|
||||
"""
|
||||
entity_type = (request.args.get("entity_type") or "").strip()
|
||||
entity_id = (request.args.get("entity_id") or "").strip()
|
||||
if not entity_type or not entity_id:
|
||||
return "<p class='text-warning'>Paramètres manquants pour le formulaire de tâche liée.</p>"
|
||||
|
||||
return render_template(
|
||||
"partials/task_quick_add_form.html",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
today=date.today().isoformat(),
|
||||
)
|
||||
57
modules/tracking/store.py
Normal file
57
modules/tracking/store.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class TrackingStore:
|
||||
"""
|
||||
Stocke les enregistrements d'emails trackés et leurs événements.
|
||||
Répertoire: Data/email_tracking
|
||||
Fichier par tracking_id: Data/email_tracking/<tracking_id>.json
|
||||
"""
|
||||
def __init__(self, base_dir: Optional[str] = None):
|
||||
base_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
self.base_dir = base_dir or os.path.join(base_root, "Data", "email_tracking")
|
||||
os.makedirs(self.base_dir, exist_ok=True)
|
||||
|
||||
def _path(self, tracking_id: str) -> str:
|
||||
return os.path.join(self.base_dir, f"{tracking_id}.json")
|
||||
|
||||
def create_record(self, tracking_id: str, record: Dict[str, Any]) -> None:
|
||||
record = dict(record)
|
||||
record.setdefault("created_at", datetime.utcnow().isoformat())
|
||||
record.setdefault("events", []) # list of {"type": "...","ts": "...", ...}
|
||||
with open(self._path(tracking_id), "w", encoding="utf-8") as f:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_record(self, tracking_id: str) -> Optional[Dict[str, Any]]:
|
||||
p = self._path(tracking_id)
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def update_record(self, tracking_id: str, updater) -> Optional[Dict[str, Any]]:
|
||||
rec = self.get_record(tracking_id)
|
||||
if rec is None:
|
||||
return None
|
||||
new_rec = updater(dict(rec)) or rec
|
||||
with open(self._path(tracking_id), "w", encoding="utf-8") as f:
|
||||
json.dump(new_rec, f, ensure_ascii=False, indent=2)
|
||||
return new_rec
|
||||
|
||||
def add_event(self, tracking_id: str, event_type: str, meta: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
meta = meta or {}
|
||||
def _upd(rec: Dict[str, Any]):
|
||||
events: List[Dict[str, Any]] = rec.get("events") or []
|
||||
events.append({"type": event_type, "ts": datetime.utcnow().isoformat(), **meta})
|
||||
rec["events"] = events
|
||||
# counters
|
||||
if event_type == "open":
|
||||
rec["opens"] = int(rec.get("opens") or 0) + 1
|
||||
if event_type == "click":
|
||||
rec["clicks"] = int(rec.get("clicks") or 0) + 1
|
||||
return rec
|
||||
|
||||
return self.update_record(tracking_id, _upd)
|
||||
Loading…
Add table
Add a link
Reference in a new issue