first commit
This commit is contained in:
commit
e6c52820cd
227 changed files with 16156 additions and 0 deletions
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(),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue