first commit

This commit is contained in:
mrtoine 2025-09-20 13:18:04 +02:00
commit e6c52820cd
227 changed files with 16156 additions and 0 deletions

View file

@ -0,0 +1,4 @@
from .task import Task
from .task_handler import TaskHandler
__all__ = ["Task", "TaskHandler"]

64
modules/tasks/task.py Normal file
View 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 {},
)

View 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
View 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(),
)