From 3369b92b11dc0f5d00791a6c90b64ee45d9cc63a Mon Sep 17 00:00:00 2001 From: sesn-odoo Date: Mon, 28 Apr 2025 08:56:51 +0200 Subject: [PATCH] des trucs --- .gitignore | 5 +- config.example.toml | 11 ++++ pyproject.toml | 2 + src/config.py | 98 +++++++++++++++++++++---------- src/leave.py | 3 +- src/main.py | 136 ++++++++++++++++++++++++++++---------------- src/model.py | 10 ++-- src/odoo_client.py | 41 ++++++------- src/storage.py | 52 ++++++++--------- src/tasks.py | 4 +- uv.lock | 55 ++++++++++++++++++ 11 files changed, 282 insertions(+), 135 deletions(-) create mode 100644 config.example.toml diff --git a/.gitignore b/.gitignore index 6db4475..eef2c34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ __pycache__/ -out/ +.env .venv +.vscode/ +config.toml +out/ diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..5ac6c3a --- /dev/null +++ b/config.example.toml @@ -0,0 +1,11 @@ +[odoo] +session_id = "your-odoo-session-id-here" + +[team] +members = [ + "gram_1", + "gram_2", + "gram_3", + "gram_4", + "gram_5" +] diff --git a/pyproject.toml b/pyproject.toml index 94c706b..48054b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,8 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "click>=8.1.8", + "pytest>=8.3.5", + "python-dotenv>=1.1.0", "requests>=2.32.3", "rich>=13.9.4", ] diff --git a/src/config.py b/src/config.py index 9f12ed2..ba2eb1c 100644 --- a/src/config.py +++ b/src/config.py @@ -1,32 +1,72 @@ -import random +import os +from dataclasses import dataclass, field +from pathlib import Path -vfyd_tags = [ - "vfyd accounting", - "vfyd discuss", - "vfyd easy", - "vfyd editor", - "vfyd js", - "vfyd marketing", - "vfyd pos", - "vfyd sale", - "vfyd security", - "vfyd spreadsheet", - "vfyd stock", - "vfyd studio", - "vfyd vidange", - "vfyd website", - "vfyd industry", - "vfyd voip", -] +import tomllib +from dotenv import load_dotenv -team = [ - "nle", - "abz", - "hael", - "roen", - "sbel", - "wasa", - "huvw", -] -random.shuffle(team) +@dataclass +class Config: + config_path: Path = Path("config.toml") + vfyd_tags: list[str] = field(default_factory=lambda: [ + "vfyd accounting", + "vfyd discuss", + "vfyd easy", + "vfyd editor", + "vfyd js", + "vfyd marketing", + "vfyd pos", + "vfyd sale", + "vfyd security", + "vfyd spreadsheet", + "vfyd stock", + "vfyd studio", + "vfyd vidange", + "vfyd website", + "vfyd industry", + "vfyd voip", + ]) + _config: dict = field(init=False, repr=False) + + def __post_init__(self): + load_dotenv() + self._config = self._load_config() + self.session_id = self._get_session_id() + self.team = self._validate_team() + + def _load_config(self) -> dict: + """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() diff --git a/src/leave.py b/src/leave.py index f0dfecd..8caa067 100644 --- a/src/leave.py +++ b/src/leave.py @@ -1,7 +1,8 @@ from dataclasses import dataclass -from model import OdooModel from datetime import datetime +from model import OdooModel + @dataclass class Leave(OdooModel): diff --git a/src/main.py b/src/main.py index ecac7ed..be266fc 100644 --- a/src/main.py +++ b/src/main.py @@ -1,17 +1,22 @@ -import click import random -import odoo_client import re -from rich import print, console -from datetime import datetime from collections import defaultdict -from config import vfyd_tags, team -from tasks import Task +from datetime import datetime +from itertools import cycle + +import click +import odoo_client +from config import config from leave import Leave +from rich import console, print from storage import DispatchStorage +from tasks import Task console = console.Console() +team = config.team +vfyd_tags = config.vfyd_tags + @click.group() def cli() -> None: @@ -24,6 +29,7 @@ def dispatch(): client = odoo_client.OdooClient() today = datetime.now().strftime("%Y-%m-%d") + domain = [ ("start_datetime", "<=", today), ("stop_datetime", ">=", today), @@ -32,27 +38,31 @@ def dispatch(): domain.extend( [("employee_id.name", "=ilike", f"%({employee})") for employee in team] ) + leaves = client.web_search_read(Leave, domain) - team_availability = dict() - for employee in team: - team_availability[employee] = (True, True) + team_availability = {employee: (True, True) for employee in team} for leave in leaves: employee = re.search(r"\((.*?)\)", leave.employee_id).group(1).lower() is_morning = leave.start_datetime.hour < 11 is_afternoon = leave.stop_datetime.hour >= 14 - if is_morning: - team_availability[employee] = (False, team_availability[employee][1]) - if is_afternoon: - team_availability[employee] = (team_availability[employee][0], False) + morning, afternoon = team_availability.get(employee, (True, True)) + team_availability[employee] = ( + morning and not is_morning, + afternoon and not is_afternoon, + ) available_employees = [ employee - for employee, availability in team_availability.items() - if availability[0] or availability[1] + for employee, (morning, afternoon) in team_availability.items() + if morning or afternoon ] + if not available_employees: + print("[red]No available employees for dispatch.[/red]") + return + previous_dispatch = DispatchStorage.load_week_to_date() previous_dispatch_ids = [ task_id for tasks in previous_dispatch.values() for task_id in tasks @@ -68,37 +78,52 @@ def dispatch(): records = client.web_search_read(Task, domain) random.shuffle(records) - dispatch = defaultdict(list) - 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) + random.shuffle(available_employees) + employee_cycle = cycle(available_employees) - DispatchStorage.save(dispatch) + new_dispatch = defaultdict(list) - domain = [ - ("stage_id", "=", 194), - ("user_ids", "=", False), - ("project_id", "=", 49), - ("tag_ids", "not in", vfyd_tags), - ("id", "in", previous_dispatch_ids), - ] - records = client.web_search_read(Task, domain) + for task in records: + employee = next(employee_cycle) + new_dispatch[employee].append(task) - merged = defaultdict(list) - for member, task_ids in previous_dispatch.items(): - 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) + merged_dispatch = defaultdict(list) - for member, tasks in out.items(): - merged[member].extend(tasks) + if previous_dispatch_ids: + domain = [ + ("stage_id", "=", 194), + ("user_ids", "=", False), + ("project_id", "=", 49), + ("tag_ids", "not in", vfyd_tags), + ("id", "in", previous_dispatch_ids), + ] + old_tasks = client.web_search_read(Task, domain) + old_tasks_by_id = {task.id: task for task in old_tasks} + else: + old_tasks_by_id = {} - for key, tasks in merged.items(): - print(f"**{key}:**") + for employee, task_ids in previous_dispatch.items(): + merged_dispatch[employee].extend( + old_tasks_by_id[tid] for tid in task_ids if tid in old_tasks_by_id + ) + + for employee, tasks in new_dispatch.items(): + merged_dispatch[employee].extend(tasks) + + dispatch_to_save = { + employee: [task.id for task in tasks] + 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: print(task.url) + print("\n") @cli.command() @@ -113,34 +138,45 @@ def status(): ("tag_ids", "not in", vfyd_tags), ] tasks = client.web_search_read(Task, domain) - for task in tasks: - print(task) + + if not tasks: + print("[yellow]No unassigned tasks in the backlog.[/yellow]") + else: + for task in tasks: + print(task) @cli.command() def check(): """Check which previously dispatched tasks are still not completed.""" - dispatch = DispatchStorage.load() - reverse_dispatch = {v: k for k, vs in dispatch.items() for v in vs} + dispatch = DispatchStorage.load_week_to_date() + task_id_to_employee = { + task_id: employee + for employee, task_ids in dispatch.items() + for task_id in task_ids + } client = odoo_client.OdooClient() domain = [ - ("id", "in", list(reverse_dispatch.keys())), + ("id", "in", list(task_id_to_employee.keys())), ("tag_ids", "not in", vfyd_tags), ("stage_id", "=", 194), ("project_id", "=", 49), ] tasks = client.web_search_read(Task, domain) + if not tasks: + print("[green]All dispatched tasks have been completed![/green]") + return + not_done = defaultdict(list) for task in tasks: - not_done[reverse_dispatch[task.id]].append(task) + not_done[task_id_to_employee[task.id]].append(task) - for key, value in not_done.items(): - if value: - console.rule(f"[red]{key}[/red]") - for task in value: - print(task) + for employee, pending_tasks in not_done.items(): + console.rule(f"[red]{employee}[/red]") + for task in pending_tasks: + print(task) if __name__ == "__main__": diff --git a/src/model.py b/src/model.py index 7050006..f66de1a 100644 --- a/src/model.py +++ b/src/model.py @@ -1,12 +1,12 @@ -from typing import Dict, Self, Any, get_origin -from dataclasses import fields, Field +from dataclasses import Field, fields from datetime import datetime +from typing import Any, Self, get_origin class OdooModel: @classmethod - def specification(cls) -> Dict[str, Any]: - def field_spec(f: Field) -> Dict[str, Any]: + def specification(cls) -> dict[str, Any]: + def field_spec(f: Field) -> dict[str, Any]: field_type = f.type if f.name.endswith("_id") or f.name.endswith("_ids"): return { @@ -29,7 +29,7 @@ class OdooModel: return spec @classmethod - def from_record(cls, record: Dict) -> Self: + def from_record(cls, record: dict) -> Self: init_args = {} for f in fields(cls): if f.init and f.name in record: diff --git a/src/odoo_client.py b/src/odoo_client.py index 6a73aa1..a9dfe80 100644 --- a/src/odoo_client.py +++ b/src/odoo_client.py @@ -1,9 +1,9 @@ -import os -import requests import random -from typing import Any, Dict, List, TypeVar, Type -from model import OdooModel +from typing import Any, TypeVar +import requests +from config import config +from model import OdooModel T = TypeVar("T", bound=OdooModel) @@ -12,20 +12,18 @@ class OdooClient: def __init__(self): self.base_url = "https://www.odoo.com" self.session = requests.Session() - if session_id := os.getenv("ODOO_SESSION_ID"): - self.session.cookies.set("session_id", session_id) - else: - raise Exception("ODOO_SESSION_ID is not set") + + self.session.cookies.set("session_id", config.session_id) self.session.cookies.set("cids", "1") self.session.cookies.set("frontend_lang", "en_US") def rpc( self, model: str, method: str, *args: Any, **kwargs: Any ) -> requests.Response: - body: Dict[str, Any] = { + body: dict[str, Any] = { "jsonrpc": "2.0", "method": method, - "id": random.randint(1, 1000000), + "id": random.randint(1, 1_000_000), "params": { "model": model, "method": method, @@ -34,23 +32,26 @@ class OdooClient: }, } - return self.session.post( + response = self.session.post( f"{self.base_url}/web/dataset/call_kw", json=body, ) + response.raise_for_status() + return response def web_search_read( self, - model: Type[T], - domain: List[tuple], + model: type[T], + domain: list[tuple[Any, ...]], *args: Any, **kwargs: Any, - ) -> List[T]: + ) -> list[T]: args = [domain, model.specification(), *args] - res = self.rpc(model._name, "web_search_read", *args, **kwargs) - res.raise_for_status() - json = res.json() - if json.get("error"): - raise Exception(json.get("error")) - records = json.get("result", {}).get("records", []) + response = self.rpc(model._name, "web_search_read", *args, **kwargs) + json_data = response.json() + + if error := json_data.get("error"): + raise RuntimeError(f"Odoo RPC Error: {error}") + + records = json_data.get("result", {}).get("records", []) return [model.from_record(record) for record in records] diff --git a/src/storage.py b/src/storage.py index c36ad8d..ae87956 100644 --- a/src/storage.py +++ b/src/storage.py @@ -1,43 +1,41 @@ -from typing import Dict, List, Optional -from datetime import datetime, timedelta import json +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional class DispatchStorage: - @classmethod - def path(cls, date: Optional[str] = None) -> str: + @staticmethod + def path(date: Optional[str] = None) -> Path: if date is None: date = datetime.now().strftime("%Y-%m-%d") - return f"out/{date}_dispatch.json" + return Path(f"out/{date}_dispatch.json") - @classmethod - def load(cls, date: Optional[str] = None) -> Dict[str, List[int]]: - with open(cls.path(date), "r") as f: + @staticmethod + def load(date: Optional[str] = None) -> dict[str, list[int]]: + path = DispatchStorage.path(date) + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as f: return json.load(f) - @classmethod - def save(cls, dispatch: Dict[str, List[int]], date: Optional[str] = None) -> None: - with open(cls.path(date), "w") as f: + @staticmethod + def save(dispatch: dict[str, list[int]], date: Optional[str] = None) -> None: + path = DispatchStorage.path(date) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: json.dump(dispatch, f) - @classmethod - def load_week_to_date(cls) -> Dict[str, List[int]]: - """ - Loads and combines dispatch data from the start of the current week up to today. - """ + @staticmethod + def load_week_to_date() -> dict[str, list[int]]: today = datetime.now() - days_since_monday = today.weekday() - current_date = today - timedelta(days=days_since_monday) + start_of_week = today - timedelta(days=today.weekday()) # Previous Monday - combined_dispatch: Dict[str, List[int]] = {} - while current_date.date() < today.date(): - date_str = current_date.strftime("%Y-%m-%d") - daily_dispatch = cls.load(date_str) + combined_dispatch: dict[str, list[int]] = {} + for i in range((today - start_of_week).days + 1): + date_str = (start_of_week + timedelta(days=i)).strftime("%Y-%m-%d") + daily_dispatch = DispatchStorage.load(date_str) for key, values in daily_dispatch.items(): - if key in combined_dispatch: - combined_dispatch[key].extend(values) - else: - combined_dispatch[key] = values.copy() - current_date += timedelta(days=1) + combined_dispatch.setdefault(key, []).extend(values) return combined_dispatch diff --git a/src/tasks.py b/src/tasks.py index 26d3501..20b8c2b 100644 --- a/src/tasks.py +++ b/src/tasks.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import List + from model import OdooModel @@ -9,7 +9,7 @@ class Task(OdooModel): id: int name: str - tag_ids: List[str] + tag_ids: list[str] stage_id: str priority: bool url: str = field(init=False) diff --git a/uv.lock b/uv.lock index c929256..5e5f582 100644 --- a/uv.lock +++ b/uv.lock @@ -76,6 +76,15 @@ wheels = [ { 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]] name = "markdown-it-py" version = "3.0.0" @@ -97,6 +106,24 @@ wheels = [ { 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]] name = "pygments" version = "2.19.1" @@ -106,6 +133,30 @@ wheels = [ { 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]] name = "requests" version = "2.32.3" @@ -140,6 +191,8 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "click" }, + { name = "pytest" }, + { name = "python-dotenv" }, { name = "requests" }, { name = "rich" }, ] @@ -147,6 +200,8 @@ dependencies = [ [package.metadata] requires-dist = [ { 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 = "rich", specifier = ">=13.9.4" }, ]