Compare commits

..

5 Commits

Author SHA1 Message Date
Hubert Van De Walle
f4df1935a9 Add team member 2025-09-15 10:33:52 +02:00
Hubert Van De Walle
eebeed58a5 des trucs
Co-authored-by: sesn-odoo <sesn@odoo.com>
2025-07-22 11:47:33 +02:00
Hubert Van De Walle
a49c5f012f Return an empty dict when loading non existant file 2025-07-22 11:23:07 +02:00
Hubert Van De Walle
7e7ffd6f49 Update tags and team 2025-07-22 11:20:50 +02:00
Hubert Van De Walle
e7fa624b42 Ignore IDE files 2025-07-22 11:20:29 +02:00
11 changed files with 118 additions and 249 deletions

7
.gitignore vendored
View File

@ -1,6 +1,5 @@
__pycache__/ __pycache__/
.env
.venv
.vscode/
config.toml
out/ out/
.venv
.idea/
.vscode/

View File

@ -1,11 +0,0 @@
[odoo]
session_id = "your-odoo-session-id-here"
[team]
members = [
"gram_1",
"gram_2",
"gram_3",
"gram_4",
"gram_5"
]

View File

@ -6,8 +6,6 @@ readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"click>=8.1.8", "click>=8.1.8",
"pytest>=8.3.5",
"python-dotenv>=1.1.0",
"requests>=2.32.3", "requests>=2.32.3",
"rich>=13.9.4", "rich>=13.9.4",
] ]

View File

@ -1,18 +1,8 @@
import os import random
from dataclasses import dataclass, field
from pathlib import Path
import tomllib vfyd_tags = [
from dotenv import load_dotenv
@dataclass
class Config:
config_path: Path = Path("config.toml")
vfyd_tags: list[str] = field(default_factory=lambda: [
"vfyd accounting", "vfyd accounting",
"vfyd discuss", "vfyd discuss",
"vfyd easy",
"vfyd editor", "vfyd editor",
"vfyd js", "vfyd js",
"vfyd marketing", "vfyd marketing",
@ -26,47 +16,20 @@ class Config:
"vfyd website", "vfyd website",
"vfyd industry", "vfyd industry",
"vfyd voip", "vfyd voip",
]) "vfyd ai",
_config: dict = field(init=False, repr=False) "vfyd iot",
]
def __post_init__(self): team = [
load_dotenv() "nle",
self._config = self._load_config() "abz",
self.session_id = self._get_session_id() "hael",
self.team = self._validate_team() "roen",
"sbel",
"wasa",
"huvw",
"artn",
"gvar",
]
def _load_config(self) -> dict: random.shuffle(team)
"""Load the configuration file."""
if not self.config_path.exists():
raise FileNotFoundError(f"Configuration file '{self.config_path}' not found.")
try:
with self.config_path.open("rb") as f:
return tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ValueError(f"Error decoding TOML file '{self.config_path}': {e}")
def _get_session_id(self) -> str:
"""Prioritize configuration sources for session ID."""
session_id = self._get("odoo", "session_id") or os.getenv("ODOO_SESSION_ID")
if not session_id:
raise ValueError("No valid session ID found in config or environment")
return session_id
def _validate_team(self) -> list[str]:
"""Validate and sanitize team members."""
team = self._get("team", "members")
if not team:
raise ValueError("No team members defined in configuration.")
return list({member.lower().strip() for member in team})
def _get(self, section: str, key: str, default=None):
"""Retrieve a configuration value."""
return self._config.get(section, {}).get(key, default)
config = Config()

View File

@ -1,7 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from model import OdooModel from model import OdooModel
from datetime import datetime
@dataclass @dataclass

View File

@ -1,22 +1,17 @@
import random
import re
from collections import defaultdict
from datetime import datetime
from itertools import cycle
import click import click
import random
import odoo_client import odoo_client
from config import config import re
from leave import Leave from rich import print, console
from rich import console, print from datetime import datetime
from storage import DispatchStorage from collections import defaultdict
from config import vfyd_tags, team
from tasks import Task from tasks import Task
from leave import Leave
from storage import DispatchStorage
console = console.Console() console = console.Console()
team = config.team
vfyd_tags = config.vfyd_tags
@click.group() @click.group()
def cli() -> None: def cli() -> None:
@ -29,7 +24,6 @@ def dispatch():
client = odoo_client.OdooClient() client = odoo_client.OdooClient()
today = datetime.now().strftime("%Y-%m-%d") today = datetime.now().strftime("%Y-%m-%d")
domain = [ domain = [
("start_datetime", "<=", today), ("start_datetime", "<=", today),
("stop_datetime", ">=", today), ("stop_datetime", ">=", today),
@ -38,7 +32,6 @@ def dispatch():
domain.extend( domain.extend(
[("employee_id.name", "=ilike", f"%({employee})") for employee in team] [("employee_id.name", "=ilike", f"%({employee})") for employee in team]
) )
leaves = client.web_search_read(Leave, domain) leaves = client.web_search_read(Leave, domain)
team_availability = {employee: (True, True) for employee in team} team_availability = {employee: (True, True) for employee in team}
@ -47,11 +40,10 @@ def dispatch():
employee = re.search(r"\((.*?)\)", leave.employee_id).group(1).lower() employee = re.search(r"\((.*?)\)", leave.employee_id).group(1).lower()
is_morning = leave.start_datetime.hour < 11 is_morning = leave.start_datetime.hour < 11
is_afternoon = leave.stop_datetime.hour >= 14 is_afternoon = leave.stop_datetime.hour >= 14
morning, afternoon = team_availability.get(employee, (True, True)) if is_morning:
team_availability[employee] = ( team_availability[employee] = (False, team_availability[employee][1])
morning and not is_morning, if is_afternoon:
afternoon and not is_afternoon, team_availability[employee] = (team_availability[employee][0], False)
)
available_employees = [ available_employees = [
employee employee
@ -78,18 +70,14 @@ def dispatch():
records = client.web_search_read(Task, domain) records = client.web_search_read(Task, domain)
random.shuffle(records) random.shuffle(records)
random.shuffle(available_employees) dispatch = defaultdict(list)
employee_cycle = cycle(available_employees) out = defaultdict(list)
for idx, task in enumerate(records):
dispatch[available_employees[idx % len(available_employees)]].append(task.id)
out[available_employees[idx % len(available_employees)]].append(task)
new_dispatch = defaultdict(list) DispatchStorage.save(dispatch)
for task in records:
employee = next(employee_cycle)
new_dispatch[employee].append(task)
merged_dispatch = defaultdict(list)
if previous_dispatch_ids:
domain = [ domain = [
("stage_id", "=", 194), ("stage_id", "=", 194),
("user_ids", "=", False), ("user_ids", "=", False),
@ -97,33 +85,22 @@ def dispatch():
("tag_ids", "not in", vfyd_tags), ("tag_ids", "not in", vfyd_tags),
("id", "in", previous_dispatch_ids), ("id", "in", previous_dispatch_ids),
] ]
old_tasks = client.web_search_read(Task, domain) records = client.web_search_read(Task, domain)
old_tasks_by_id = {task.id: task for task in old_tasks}
else:
old_tasks_by_id = {}
for employee, task_ids in previous_dispatch.items(): merged = defaultdict(list)
merged_dispatch[employee].extend( for member, task_ids in previous_dispatch.items():
old_tasks_by_id[tid] for tid in task_ids if tid in old_tasks_by_id for task_id in task_ids:
) task = next((t for t in records if t.id == task_id), None)
if task:
merged[member].append(task)
for employee, tasks in new_dispatch.items(): for member, tasks in out.items():
merged_dispatch[employee].extend(tasks) merged[member].extend(tasks)
dispatch_to_save = { for key, tasks in merged.items():
employee: [task.id for task in tasks] print(f"**{key}:**")
for employee, tasks in merged_dispatch.items()
}
DispatchStorage.save(dispatch_to_save)
for employee, tasks in sorted(merged_dispatch.items()):
if not tasks:
continue
print(f"**{employee}:**")
for task in tasks: for task in tasks:
print(task.url) print(task.url)
print("\n")
@cli.command() @cli.command()
@ -138,10 +115,6 @@ def status():
("tag_ids", "not in", vfyd_tags), ("tag_ids", "not in", vfyd_tags),
] ]
tasks = client.web_search_read(Task, domain) tasks = client.web_search_read(Task, domain)
if not tasks:
print("[yellow]No unassigned tasks in the backlog.[/yellow]")
else:
for task in tasks: for task in tasks:
print(task) print(task)
@ -150,32 +123,25 @@ def status():
def check(): def check():
"""Check which previously dispatched tasks are still not completed.""" """Check which previously dispatched tasks are still not completed."""
dispatch = DispatchStorage.load_week_to_date() dispatch = DispatchStorage.load()
task_id_to_employee = { reverse_dispatch = {v: k for k, vs in dispatch.items() for v in vs}
task_id: employee
for employee, task_ids in dispatch.items()
for task_id in task_ids
}
client = odoo_client.OdooClient() client = odoo_client.OdooClient()
domain = [ domain = [
("id", "in", list(task_id_to_employee.keys())), ("id", "in", list(reverse_dispatch.keys())),
("tag_ids", "not in", vfyd_tags), ("tag_ids", "not in", vfyd_tags),
("stage_id", "=", 194), ("stage_id", "=", 194),
("project_id", "=", 49), ("project_id", "=", 49),
] ]
tasks = client.web_search_read(Task, domain) tasks = client.web_search_read(Task, domain)
if not tasks:
print("[green]All dispatched tasks have been completed![/green]")
return
not_done = defaultdict(list) not_done = defaultdict(list)
for task in tasks: for task in tasks:
not_done[task_id_to_employee[task.id]].append(task) not_done[reverse_dispatch[task.id]].append(task)
for employee, pending_tasks in not_done.items(): for key, value in not_done.items():
console.rule(f"[red]{employee}[/red]") if value:
for task in pending_tasks: console.rule(f"[red]{key}[/red]")
for task in value:
print(task) print(task)

View File

@ -1,6 +1,6 @@
from dataclasses import Field, fields from typing import Self, Any, get_origin
from dataclasses import fields, Field
from datetime import datetime from datetime import datetime
from typing import Any, Self, get_origin
class OdooModel: class OdooModel:

View File

@ -1,10 +1,10 @@
import os
import requests
import random import random
from typing import Any, TypeVar from typing import Any, TypeVar
import requests
from config import config
from model import OdooModel from model import OdooModel
T = TypeVar("T", bound=OdooModel) T = TypeVar("T", bound=OdooModel)
@ -12,8 +12,10 @@ class OdooClient:
def __init__(self): def __init__(self):
self.base_url = "https://www.odoo.com" self.base_url = "https://www.odoo.com"
self.session = requests.Session() self.session = requests.Session()
if session_id := os.getenv("ODOO_SESSION_ID"):
self.session.cookies.set("session_id", config.session_id) self.session.cookies.set("session_id", session_id)
else:
raise Exception("ODOO_SESSION_ID is not set")
self.session.cookies.set("cids", "1") self.session.cookies.set("cids", "1")
self.session.cookies.set("frontend_lang", "en_US") self.session.cookies.set("frontend_lang", "en_US")
@ -47,11 +49,12 @@ class OdooClient:
**kwargs: Any, **kwargs: Any,
) -> list[T]: ) -> list[T]:
args = [domain, model.specification(), *args] args = [domain, model.specification(), *args]
response = self.rpc(model._name, "web_search_read", *args, **kwargs) res = self.rpc(model._name, "web_search_read", *args, **kwargs)
json_data = response.json() res.raise_for_status()
json = res.json()
if error := json_data.get("error"): if error := json.get("error"):
raise RuntimeError(f"Odoo RPC Error: {error}") raise RuntimeError(f"Odoo RPC Error: {error}")
records = json_data.get("result", {}).get("records", []) records = json.get("result", {}).get("records", [])
return [model.from_record(record) for record in records] return [model.from_record(record) for record in records]

View File

@ -1,7 +1,7 @@
import json from typing import Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Optional import json
class DispatchStorage: class DispatchStorage:
@ -9,33 +9,41 @@ class DispatchStorage:
def path(date: Optional[str] = None) -> Path: def path(date: Optional[str] = None) -> Path:
if date is None: if date is None:
date = datetime.now().strftime("%Y-%m-%d") date = datetime.now().strftime("%Y-%m-%d")
return Path(f"out/{date}_dispatch.json") return Path("out", f"{date}_dispatch.json")
@staticmethod @staticmethod
def load(date: Optional[str] = None) -> dict[str, list[int]]: def load(date: Optional[str] = None) -> dict[str, list[int]]:
path = DispatchStorage.path(date) try:
if not path.exists(): with open(DispatchStorage.path(date), "r") as f:
return {}
with path.open("r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
except FileNotFoundError:
return {}
@staticmethod @staticmethod
def save(dispatch: dict[str, list[int]], date: Optional[str] = None) -> None: def save(dispatch: dict[str, list[int]], date: Optional[str] = None) -> None:
path = DispatchStorage.path(date) path = DispatchStorage.path(date)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(exist_ok=True)
with path.open("w", encoding="utf-8") as f: with open(path, "w") as f:
json.dump(dispatch, f) json.dump(dispatch, f)
@staticmethod @staticmethod
def load_week_to_date() -> dict[str, list[int]]: def load_week_to_date() -> dict[str, list[int]]:
"""
Loads and combines dispatch data from the start of the current week up to today.
"""
today = datetime.now() today = datetime.now()
start_of_week = today - timedelta(days=today.weekday()) # Previous Monday days_since_monday = today.weekday()
current_date = today - timedelta(days=days_since_monday)
combined_dispatch: dict[str, list[int]] = {} combined_dispatch: dict[str, list[int]] = {}
for i in range((today - start_of_week).days + 1): while current_date.date() < today.date():
date_str = (start_of_week + timedelta(days=i)).strftime("%Y-%m-%d") date_str = current_date.strftime("%Y-%m-%d")
daily_dispatch = DispatchStorage.load(date_str) daily_dispatch = DispatchStorage.load(date_str)
for key, values in daily_dispatch.items(): for key, values in daily_dispatch.items():
combined_dispatch.setdefault(key, []).extend(values) if key in combined_dispatch:
combined_dispatch[key].extend(values)
else:
combined_dispatch[key] = values.copy()
current_date += timedelta(days=1)
return combined_dispatch return combined_dispatch

View File

@ -1,5 +1,4 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from model import OdooModel from model import OdooModel

55
uv.lock generated
View File

@ -76,15 +76,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
] ]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "3.0.0" version = "3.0.0"
@ -106,24 +97,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
] ]
[[package]]
name = "packaging"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.1" version = "2.19.1"
@ -133,30 +106,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
] ]
[[package]]
name = "pytest"
version = "8.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
]
[[package]]
name = "python-dotenv"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.3" version = "2.32.3"
@ -191,8 +140,6 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "pytest" },
{ name = "python-dotenv" },
{ name = "requests" }, { name = "requests" },
{ name = "rich" }, { name = "rich" },
] ]
@ -200,8 +147,6 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.1.8" }, { name = "click", specifier = ">=8.1.8" },
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "rich", specifier = ">=13.9.4" }, { name = "rich", specifier = ">=13.9.4" },
] ]