From 4782a41abb20f00d2a3ffd268dead204a4574eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B0=D0=B2=D0=BA=D0=BE=D0=B2=20=D0=9D=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Thu, 14 May 2026 13:17:58 +0300 Subject: [PATCH] Add GIP task tools to beta panel --- .../SendTask.pushbutton/bundle.yaml | 3 + .../SendTask.pushbutton/icon.png | Bin 0 -> 6047 bytes .../SendTask.pushbutton/script.py | 20 + .../TaskJournal.pushbutton/bundle.yaml | 3 + .../TaskJournal.pushbutton/icon.png | Bin 0 -> 2320 bytes .../TaskJournal.pushbutton/script.py | 20 + pyrevit.extension/lib/gip_tasks/__init__.py | 5 + pyrevit.extension/lib/gip_tasks/api_client.py | 97 ++++ pyrevit.extension/lib/gip_tasks/commands.py | 52 ++ pyrevit.extension/lib/gip_tasks/config.py | 96 ++++ .../lib/gip_tasks/local_cache.py | 265 ++++++++++ pyrevit.extension/lib/gip_tasks/logger.py | 24 + .../lib/gip_tasks/marker_service.py | 185 +++++++ .../lib/gip_tasks/project_service.py | 126 +++++ .../lib/gip_tasks/revit_context.py | 61 +++ .../lib/gip_tasks/sync_service.py | 18 + .../lib/gip_tasks/task_models.py | 94 ++++ .../lib/gip_tasks/task_service.py | 84 +++ .../lib/gip_tasks/ui/__init__.py | 2 + .../lib/gip_tasks/ui/create_project_window.py | 32 ++ .../gip_tasks/ui/create_project_window.xaml | 24 + .../lib/gip_tasks/ui/create_task_window.py | 304 +++++++++++ .../lib/gip_tasks/ui/create_task_window.xaml | 288 +++++++++++ .../lib/gip_tasks/ui/select_project_window.py | 69 +++ .../gip_tasks/ui/select_project_window.xaml | 26 + .../lib/gip_tasks/ui/task_journal_window.py | 484 ++++++++++++++++++ .../lib/gip_tasks/ui/task_journal_window.xaml | 401 +++++++++++++++ pyrevit.extension/lib/gip_tasks/ui/theme.py | 309 +++++++++++ 28 files changed, 3092 insertions(+) create mode 100644 pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/bundle.yaml create mode 100644 pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/icon.png create mode 100644 pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/script.py create mode 100644 pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/bundle.yaml create mode 100644 pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/icon.png create mode 100644 pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/script.py create mode 100644 pyrevit.extension/lib/gip_tasks/__init__.py create mode 100644 pyrevit.extension/lib/gip_tasks/api_client.py create mode 100644 pyrevit.extension/lib/gip_tasks/commands.py create mode 100644 pyrevit.extension/lib/gip_tasks/config.py create mode 100644 pyrevit.extension/lib/gip_tasks/local_cache.py create mode 100644 pyrevit.extension/lib/gip_tasks/logger.py create mode 100644 pyrevit.extension/lib/gip_tasks/marker_service.py create mode 100644 pyrevit.extension/lib/gip_tasks/project_service.py create mode 100644 pyrevit.extension/lib/gip_tasks/revit_context.py create mode 100644 pyrevit.extension/lib/gip_tasks/sync_service.py create mode 100644 pyrevit.extension/lib/gip_tasks/task_models.py create mode 100644 pyrevit.extension/lib/gip_tasks/task_service.py create mode 100644 pyrevit.extension/lib/gip_tasks/ui/__init__.py create mode 100644 pyrevit.extension/lib/gip_tasks/ui/create_project_window.py create mode 100644 pyrevit.extension/lib/gip_tasks/ui/create_project_window.xaml create mode 100644 pyrevit.extension/lib/gip_tasks/ui/create_task_window.py create mode 100644 pyrevit.extension/lib/gip_tasks/ui/create_task_window.xaml create mode 100644 pyrevit.extension/lib/gip_tasks/ui/select_project_window.py create mode 100644 pyrevit.extension/lib/gip_tasks/ui/select_project_window.xaml create mode 100644 pyrevit.extension/lib/gip_tasks/ui/task_journal_window.py create mode 100644 pyrevit.extension/lib/gip_tasks/ui/task_journal_window.xaml create mode 100644 pyrevit.extension/lib/gip_tasks/ui/theme.py diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/bundle.yaml b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/bundle.yaml new file mode 100644 index 0000000..1330882 --- /dev/null +++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/bundle.yaml @@ -0,0 +1,3 @@ +title: "Передать\nзадание" +tooltip: "Создать и передать задание между разделами" +author: CPSK diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/icon.png b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3f9af65e85eb4c75a3b6898bf2b1f8ff429879f4 GIT binary patch literal 6047 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 literal 0 HcmV?d00001 diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/script.py b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/script.py new file mode 100644 index 0000000..940645a --- /dev/null +++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/script.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""Открывает журнал заданий между разделами.""" + +__title__ = "Журнал\nзаданий" +__author__ = "CPSK" + +import os +import sys + + +SCRIPT_DIR = os.path.dirname(__file__) +EXTENSION_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(SCRIPT_DIR)))) +LIB_DIR = os.path.join(EXTENSION_DIR, "lib") +if LIB_DIR not in sys.path: + sys.path.insert(0, LIB_DIR) + +from gip_tasks import commands + + +commands.run_journal(__revit__.ActiveUIDocument) diff --git a/pyrevit.extension/lib/gip_tasks/__init__.py b/pyrevit.extension/lib/gip_tasks/__init__.py new file mode 100644 index 0000000..9b7a1d8 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +"""GIP Tasks MVP package for pyRevit.""" + +APP_NAME = "GIP Tasks" + diff --git a/pyrevit.extension/lib/gip_tasks/api_client.py b/pyrevit.extension/lib/gip_tasks/api_client.py new file mode 100644 index 0000000..44aca7f --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/api_client.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +import json +import sys +try: + import urllib2 + from urllib import urlencode +except ImportError: + import urllib.request as urllib2 + from urllib.parse import urlencode + +from . import config + + +DEFAULT_TIMEOUT_SECONDS = 3 + + +class ApiError(Exception): + pass + + +class ApiClient(object): + def __init__(self, settings=None): + self.settings = settings or config.load() + self.base_url = (self.settings.get("API_BASE_URL") or "").rstrip("/") + self.token = self.settings.get("AUTH_TOKEN") or "" + self.timeout = self._timeout() + + def _timeout(self): + try: + return max(1, int(self.settings.get("API_TIMEOUT_SECONDS") or DEFAULT_TIMEOUT_SECONDS)) + except Exception: + return DEFAULT_TIMEOUT_SECONDS + + def request(self, method, path, payload=None, query=None): + if not self.base_url: + raise ApiError("API_BASE_URL is empty") + url = self.base_url + path + if query: + clean = {} + for key, value in query.items(): + if value is not None and value != "": + clean[key] = value + if clean: + url += "?" + urlencode(clean) + body = None + headers = {"Accept": "application/json", "Content-Type": "application/json"} + if self.token: + headers["Authorization"] = "Bearer " + self.token + if payload is not None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib2.Request(url, data=body, headers=headers) + req.get_method = lambda: method + try: + res = urllib2.urlopen(req, timeout=self.timeout) + raw = res.read() + if not raw: + return {} + if sys.version_info[0] < 3: + raw = raw.decode("utf-8") + return json.loads(raw) + except Exception as exc: + raise ApiError(str(exc)) + + def get_projects(self): + return self.request("GET", "/projects") + + def create_project(self, payload): + return self.request("POST", "/projects", payload) + + def list_tasks(self, project_id, filters=None): + if not project_id: + raise ApiError(u"Сначала выберите проект") + query = {"project_id": project_id} + query.update(filters or {}) + return self.request("GET", "/tasks", query=query) + + def create_task(self, payload): + if not payload.get("project_id"): + raise ApiError(u"Сначала выберите проект") + return self.request("POST", "/tasks", payload) + + def patch_task(self, task_id, project_id, payload): + if not project_id: + raise ApiError(u"Сначала выберите проект") + payload = payload or {} + payload["project_id"] = project_id + return self.request("PATCH", "/tasks/%s" % task_id, payload) + + def add_comment(self, task_id, project_id, comment): + if not project_id: + raise ApiError(u"Сначала выберите проект") + return self.request("POST", "/tasks/%s/comments" % task_id, {"project_id": project_id, "comment": comment}) + + def task_changes(self, project_id, since=None): + if not project_id: + raise ApiError(u"Сначала выберите проект") + return self.request("GET", "/tasks/changes", query={"project_id": project_id, "since": since}) diff --git a/pyrevit.extension/lib/gip_tasks/commands.py b/pyrevit.extension/lib/gip_tasks/commands.py new file mode 100644 index 0000000..7278da7 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/commands.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from pyrevit import forms + + +def show_short_error(message): + forms.alert(message, title="GIP Tasks") + + +def log_exception(message): + try: + from . import logger + logger.exception(message) + except Exception: + pass + + +def run_create_task(uidoc): + try: + from . import config, revit_context, task_models, task_service + from . import marker_service + from .ui.create_task_window import CreateTaskWindow + + doc = uidoc.Document + settings = config.load() + ctx = revit_context.collect(uidoc) + win = CreateTaskWindow(settings, ctx, uidoc) + if not win.show_dialog(): + return None + marker = win.selected_marker + if not marker: + show_short_error(u"Сначала выберите маркер задания семейства CPSK_Маркер задания на активном виде.") + return None + payload = task_models.new_task_payload(config.load(), win.result, win.revit_context) + payload, status = task_service.create_task(payload) + marker_service.write_task_to_marker(doc, marker, payload) + forms.alert(status, title="GIP Tasks", warn_icon=False) + return payload + except Exception: + log_exception("Create task command failed") + show_short_error(u"Не удалось передать задание. Подробности записаны в лог.") + return None + + +def run_journal(uidoc): + try: + from .ui.task_journal_window import TaskJournalWindow + + win = TaskJournalWindow(uidoc) + win.show_dialog() + except Exception: + log_exception("Journal command failed") + show_short_error(u"Не удалось открыть журнал. Подробности записаны в лог.") diff --git a/pyrevit.extension/lib/gip_tasks/config.py b/pyrevit.extension/lib/gip_tasks/config.py new file mode 100644 index 0000000..b6cd968 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/config.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +import json +import os + + +DEFAULTS = { + "API_BASE_URL": "", + "AUTH_TOKEN": "", + "COMPANY_ID": "demo-company", + "CURRENT_PROJECT_ID": "", + "CURRENT_PROJECT_NAME": "", + "CURRENT_PROJECT_CODE": "", + "CURRENT_DISCIPLINE": "КЖ", + "CURRENT_USER": "", + "CURRENT_USER_ROLE": "project_coordinator", + "LOCAL_MODE": True, + "LOCAL_CACHE_PATH": "", + "API_TIMEOUT_SECONDS": 3, + "POLLING_INTERVAL_SECONDS": 30, + "MARKER_FAMILY_NAME": "CPSK_Маркер задания", + "MARKER_FAMILY_TYPE": "Новое", + "MARKER_FAMILY_PATH": r"C:\Users\saukouma\Documents\BIM библиотека\2-Семейства\Задания\CPSK_Маркер задания.rfa", +} + + +def _ensure_dir(path): + if path and not os.path.isdir(path): + os.makedirs(path) + return path + + +def appdata_dir(): + base = os.environ.get("APPDATA") or os.path.expanduser("~") + return _ensure_dir(os.path.join(base, "GIPTasks")) + + +def extension_root(): + return os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) + + +def config_path(): + return os.path.join(appdata_dir(), "config.json") + + +def _normalize(data): + data["MARKER_FAMILY_NAME"] = DEFAULTS["MARKER_FAMILY_NAME"] + data["MARKER_FAMILY_TYPE"] = DEFAULTS["MARKER_FAMILY_TYPE"] + if not data.get("CURRENT_DISCIPLINE"): + data["CURRENT_DISCIPLINE"] = DEFAULTS["CURRENT_DISCIPLINE"] + if not data.get("LOCAL_CACHE_PATH"): + data["LOCAL_CACHE_PATH"] = os.path.join(appdata_dir(), "gip_tasks_cache.sqlite") + data["LOCAL_MODE"] = not bool((data.get("API_BASE_URL") or "").strip()) + return data + + +def load(): + data = DEFAULTS.copy() + path = config_path() + if os.path.exists(path): + try: + with open(path, "rb") as fp: + data.update(json.loads(fp.read().decode("utf-8"))) + except Exception: + pass + return _normalize(data) + + +def save(values): + data = DEFAULTS.copy() + data.update(values or {}) + data = _normalize(data) + _ensure_dir(os.path.dirname(config_path())) + raw = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True) + with open(config_path(), "wb") as fp: + fp.write(raw.encode("utf-8")) + return data + + +def set_current_project(project): + data = load() + data["CURRENT_PROJECT_ID"] = project.get("id") or project.get("project_id") or "" + data["CURRENT_PROJECT_NAME"] = project.get("name") or project.get("project_name") or "" + data["CURRENT_PROJECT_CODE"] = project.get("code") or project.get("project_code") or "" + if project.get("user_role"): + data["CURRENT_USER_ROLE"] = project.get("user_role") + return save(data) + + +def can_create_project(settings=None): + role = (settings or load()).get("CURRENT_USER_ROLE") or "" + return role in ("admin", "project_coordinator", "lead_specialist") + + +def is_local_mode(settings=None): + settings = settings or load() + return bool(settings.get("LOCAL_MODE")) or not bool((settings.get("API_BASE_URL") or "").strip()) diff --git a/pyrevit.extension/lib/gip_tasks/local_cache.py b/pyrevit.extension/lib/gip_tasks/local_cache.py new file mode 100644 index 0000000..47c3498 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/local_cache.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +import json +import os +import uuid +try: + import sqlite3 +except Exception: + sqlite3 = None + +from . import config, logger, task_models + + +def connect(settings=None): + settings = settings or config.load() + if sqlite3 is None: + return None + con = sqlite3.connect(settings.get("LOCAL_CACHE_PATH")) + con.row_factory = sqlite3.Row + init(con) + return con + + +def init(con): + con.execute("""create table if not exists projects ( + id text primary key, + project_json text not null, + updated_at text not null + )""") + con.execute("""create table if not exists tasks ( + id text primary key, + project_id text not null, + task_json text not null, + updated_at text not null + )""") + con.execute("""create table if not exists pending_queue ( + id integer primary key autoincrement, + action text not null, + project_id text not null, + payload_json text not null, + created_at text not null, + attempts integer default 0, + last_error text + )""") + con.execute("create table if not exists sync_state (project_id text primary key, last_sync text)") + con.commit() + + +def _json_path(name): + return os.path.join(config.appdata_dir(), name) + + +def _read_json(path, fallback): + if not os.path.exists(path): + return fallback + try: + with open(path, "rb") as fp: + return json.loads(fp.read().decode("utf-8")) + except Exception: + logger.exception("Failed to read json cache: %s" % path) + return fallback + + +def _write_json(path, data): + raw = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True) + with open(path, "wb") as fp: + fp.write(raw.encode("utf-8")) + + +def local_projects_path(): + return _json_path("local_projects.json") + + +def list_local_projects(): + con = connect() + if con is not None: + rows = con.execute("select project_json from projects order by updated_at desc").fetchall() + con.close() + return [json.loads(r["project_json"]) for r in rows] + return _read_json(local_projects_path(), {"items": []}).get("items") or [] + + +def save_local_project(payload, last_error=""): + project_id = payload.get("id") or payload.get("project_id") or "local-project-" + str(uuid.uuid4()) + project = { + "id": project_id, + "company_id": payload.get("company_id") or "", + "name": payload.get("name") or payload.get("project_name") or u"Локальный проект", + "code": payload.get("code") or payload.get("project_code") or "", + "customer": payload.get("customer") or "", + "address": payload.get("address") or "", + "description": payload.get("description") or "", + "created_at": payload.get("created_at") or task_models.now_iso(), + "updated_at": task_models.now_iso(), + "user_role": payload.get("user_role") or "project_coordinator", + "is_local": True, + "is_active": bool(payload.get("is_active", True)), + "last_error": last_error or "", + } + cache_project(project) + return project + + +def cache_project(project): + project_id = project.get("id") or project.get("project_id") + if not project_id: + return + project["updated_at"] = project.get("updated_at") or task_models.now_iso() + con = connect() + if con is not None: + con.execute( + "insert or replace into projects(id, project_json, updated_at) values(?,?,?)", + (project_id, json.dumps(project, ensure_ascii=False), project["updated_at"]), + ) + con.commit() + con.close() + return + projects = [x for x in list_local_projects() if x.get("id") != project_id] + projects.append(project) + _write_json(local_projects_path(), {"items": projects}) + + +def cache_task(task): + task_id = task.get("id") or task.get("temporary_id") + project_id = task.get("project_id") + if not task_id or not project_id: + return + task["updated_at"] = task.get("updated_at") or task_models.now_iso() + con = connect() + if con is not None: + con.execute( + "insert or replace into tasks(id, project_id, task_json, updated_at) values(?,?,?,?)", + (task_id, project_id, json.dumps(task, ensure_ascii=False), task["updated_at"]), + ) + con.commit() + con.close() + return + path = _json_path("local_tasks.json") + items = _read_json(path, {"items": []}).get("items") or [] + items = [x for x in items if (x.get("id") or x.get("temporary_id")) != task_id] + items.append(task) + _write_json(path, {"items": items}) + + +def list_tasks(project_id): + if not project_id: + return [] + con = connect() + if con is not None: + rows = con.execute("select task_json from tasks where project_id=? order by updated_at desc", (project_id,)).fetchall() + con.close() + return [json.loads(r["task_json"]) for r in rows] + items = _read_json(_json_path("local_tasks.json"), {"items": []}).get("items") or [] + return [x for x in items if x.get("project_id") == project_id] + + +def get_task(task_id, project_id=None): + for task in list_tasks(project_id) if project_id else _all_tasks(): + if task.get("id") == task_id or task.get("temporary_id") == task_id: + return task + return None + + +def _all_tasks(): + con = connect() + if con is not None: + rows = con.execute("select task_json from tasks order by updated_at desc").fetchall() + con.close() + return [json.loads(r["task_json"]) for r in rows] + return _read_json(_json_path("local_tasks.json"), {"items": []}).get("items") or [] + + +def update_task(project_id, task_id, updates): + task = get_task(task_id, project_id) + if not task: + return None + task.update(updates or {}) + task["updated_at"] = task_models.now_iso() + cache_task(task) + return task + + +def add_comment(project_id, task_id, comment): + task = get_task(task_id, project_id) + if not task: + return None + comments = task.get("comments") or [] + comments.append({"text": comment, "created_at": task_models.now_iso()}) + task["comments"] = comments + if comment: + existing = task.get("comment") or "" + task["comment"] = (existing + "\n" + comment).strip() if existing else comment + task["updated_at"] = task_models.now_iso() + cache_task(task) + return task + + +def enqueue(action, payload): + project_id = payload.get("project_id") or payload.get("id") + if not project_id: + return + con = connect() + if con is None: + return + con.execute( + "insert into pending_queue(action, project_id, payload_json, created_at) values(?,?,?,?)", + (action, project_id, json.dumps(payload, ensure_ascii=False), task_models.now_iso()), + ) + con.commit() + con.close() + + +def pending(project_id, limit=50): + con = connect() + if con is None: + return [] + rows = con.execute("select * from pending_queue where project_id=? order by id limit ?", (project_id, limit)).fetchall() + con.close() + return [dict(r) for r in rows] + + +def pending_count(project_id): + con = connect() + if con is None: + return 0 + row = con.execute("select count(*) as c from pending_queue where project_id=?", (project_id,)).fetchone() + con.close() + return int(row["c"] or 0) + + +def delete_pending(item_id): + con = connect() + if con is None: + return + con.execute("delete from pending_queue where id=?", (item_id,)) + con.commit() + con.close() + + +def mark_pending_error(item_id, error): + con = connect() + if con is None: + return + con.execute("update pending_queue set attempts=attempts+1, last_error=? where id=?", (error, item_id)) + con.commit() + con.close() + logger.write("Pending queue error: %s" % error) + + +def get_last_sync(project_id): + con = connect() + if con is None: + return "" + row = con.execute("select last_sync from sync_state where project_id=?", (project_id,)).fetchone() + con.close() + return row["last_sync"] if row else "" + + +def set_last_sync(project_id, value): + con = connect() + if con is None: + return + con.execute("insert or replace into sync_state(project_id,last_sync) values(?,?)", (project_id, value)) + con.commit() + con.close() + diff --git a/pyrevit.extension/lib/gip_tasks/logger.py b/pyrevit.extension/lib/gip_tasks/logger.py new file mode 100644 index 0000000..5b45471 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/logger.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +import os +import traceback +from datetime import datetime + +from . import config + + +def log_path(): + return os.path.join(config.appdata_dir(), "gip_tasks.log") + + +def write(message): + try: + line = "[%s] %s\n" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), message) + with open(log_path(), "ab") as fp: + fp.write(line.encode("utf-8")) + except Exception: + pass + + +def exception(message): + write("%s\n%s" % (message, traceback.format_exc())) + diff --git a/pyrevit.extension/lib/gip_tasks/marker_service.py b/pyrevit.extension/lib/gip_tasks/marker_service.py new file mode 100644 index 0000000..b32a7e1 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/marker_service.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +try: + unicode +except NameError: + unicode = str + +from datetime import datetime + +try: + from Autodesk.Revit.DB import BuiltInCategory, ElementId, StorageType, Transaction + from Autodesk.Revit.Exceptions import OperationCanceledException + from Autodesk.Revit.UI.Selection import ISelectionFilter, ObjectType +except Exception: + BuiltInCategory = ElementId = StorageType = Transaction = None + OperationCanceledException = Exception + ISelectionFilter = object + ObjectType = None + +from . import config, logger, task_models + + +MARKER_FAMILY_NAME = "CPSK_Маркер задания" +TEXT_LIMIT = 950 +WRITABLE_MARKER_PARAMS = [ + u"CPSK_Дата", + u"CPSK_Статус", + u"CPSK_Пометка", + u"CPSK_Комментарии", + u"CPSK_Дисциплина", + u"Новое", + u"Принято", + u"Отменено", +] + + +class CpskMarkerSelectionFilter(ISelectionFilter): + def AllowElement(self, element): + return is_cpsk_marker(element) + + def AllowReference(self, reference, position): + return False + + +def is_cpsk_marker(element): + try: + if not element or not element.Category: + return False + if element.Category.Id.IntegerValue != int(BuiltInCategory.OST_DetailComponents): + return False + return marker_family_name(element) == MARKER_FAMILY_NAME + except Exception: + return False + + +def marker_family_name(element): + try: + return element.Symbol.Family.Name + except Exception: + return "" + + +def pick_marker(uidoc): + message = u"Выберите на активном виде маркер задания.\nКатегория: Элементы узлов.\nСемейство: CPSK_Маркер задания." + try: + ref = uidoc.Selection.PickObject(ObjectType.Element, CpskMarkerSelectionFilter(), message) + marker = uidoc.Document.GetElement(ref.ElementId) + if not is_cpsk_marker(marker): + return None, u"Выбран не маркер CPSK_Маркер задания" + return marker, "" + except OperationCanceledException: + return None, u"" + except Exception: + logger.exception("Marker selection failed") + return None, u"Маркер не выбран" + + +def marker_datetime_text(): + return datetime.now().strftime("%d.%m.%Y %H:%M") + + +def set_marker_parameter_safe(element, param_name, value): + try: + param = element.LookupParameter(param_name) + if not param: + logger.write("Marker parameter not found: %s" % param_name) + return False + if param.IsReadOnly: + logger.write("Marker parameter is read-only: %s" % param_name) + return False + storage = param.StorageType + if storage == StorageType.Integer: + param.Set(1 if value in (True, 1, "1", "true", "True", u"Да") else 0) + elif storage == StorageType.Double: + logger.write("Skip numeric marker parameter: %s" % param_name) + return False + else: + param.Set("" if value is None else unicode(value)) + return True + except Exception: + logger.exception("Failed to set marker parameter: %s" % param_name) + return False + + +def marker_parameter_text(element, param_name): + try: + param = element.LookupParameter(param_name) + if not param: + return u"" + if param.StorageType == StorageType.Integer: + return unicode(param.AsInteger()) + if param.StorageType == StorageType.Double: + try: + return unicode(param.AsValueString() or "") + except Exception: + return unicode(param.AsDouble()) + try: + return unicode(param.AsString() or param.AsValueString() or "") + except Exception: + return unicode(param.AsValueString() or "") + except Exception: + return u"" + + +def marker_parameter_values(element): + values = {} + for param_name in WRITABLE_MARKER_PARAMS: + value = marker_parameter_text(element, param_name) + if value not in (None, u""): + values[param_name] = value + return values + + +def marker_status(status): + return task_models.server_to_marker_status(status) + + +def apply_marker_status(element, status): + status = marker_status(status) + set_marker_parameter_safe(element, u"CPSK_Статус", status) + set_marker_parameter_safe(element, u"Новое", status == u"Новое") + set_marker_parameter_safe(element, u"Принято", status == u"Принято") + set_marker_parameter_safe(element, u"Отменено", status == u"Отменено") + + +def _comment_text(task): + parts = [] + if task.get("description"): + parts.append(task.get("description")) + if task.get("comment"): + parts.append(task.get("comment")) + text = "\n".join(parts) + if len(text) > TEXT_LIMIT: + logger.write("Marker comments truncated for task %s" % (task.get("id") or task.get("temporary_id") or "")) + return text[:TEXT_LIMIT - 1] + return text + + +def write_task_to_marker(doc, marker, task, update_date=True): + if not marker: + raise ValueError(u"Маркер не выбран") + if not is_cpsk_marker(marker): + raise ValueError(u"Выбран не маркер CPSK_Маркер задания") + tx = Transaction(doc, "Записать задание в маркер") + tx.Start() + try: + apply_marker_status(marker, task.get("marker_status") or task.get("status") or u"Новое") + if update_date: + set_marker_parameter_safe(marker, u"CPSK_Дата", marker_datetime_text()) + set_marker_parameter_safe(marker, u"CPSK_Пометка", task.get("type") or u"прочее") + set_marker_parameter_safe(marker, u"CPSK_Комментарии", _comment_text(task)) + set_marker_parameter_safe(marker, u"CPSK_Дисциплина", u"%s → %s" % (task.get("sender_discipline") or "", task.get("receiver_discipline") or "")) + tx.Commit() + except Exception: + tx.RollBack() + logger.exception("Failed to write task to marker") + raise + + +def find_marker_by_unique_id(doc, unique_id): + if not unique_id: + return None + try: + return doc.GetElement(unique_id) + except Exception: + return None diff --git a/pyrevit.extension/lib/gip_tasks/project_service.py b/pyrevit.extension/lib/gip_tasks/project_service.py new file mode 100644 index 0000000..7d79662 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/project_service.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +try: + basestring +except NameError: + basestring = str + +import json + +from . import api_client, config, local_cache, logger + + +def normalize_items(data): + if isinstance(data, dict) and "items" in data: + return data.get("items") or [] + if isinstance(data, list): + return data + return [] + + +def merge_projects(server_projects, local_projects): + result = [] + seen = set() + for project in (server_projects or []) + (local_projects or []): + pid = project.get("id") or project.get("project_id") + if pid and pid not in seen: + seen.add(pid) + result.append(project) + return result + + +def current_project_from_settings(settings): + project_id = settings.get("CURRENT_PROJECT_ID") or "" + if not project_id: + return None + return { + "id": project_id, + "name": settings.get("CURRENT_PROJECT_NAME") or project_id, + "code": settings.get("CURRENT_PROJECT_CODE") or "", + "user_role": settings.get("CURRENT_USER_ROLE") or "", + "is_local": config.is_local_mode(settings), + } + + +def cached_projects(settings=None): + settings = settings or config.load() + projects = local_cache.list_local_projects() + current = current_project_from_settings(settings) + if current: + projects = merge_projects(projects, [current]) + return projects + + +def load_projects(use_server=True): + settings = config.load() + local_projects = cached_projects(settings) + if not use_server: + if config.is_local_mode(settings): + return local_projects, u"Локальный режим" + return local_projects, u"Показан локальный кэш. Нажмите «Обновить» для синхронизации" + if config.is_local_mode(settings): + return local_projects, u"Сервер не настроен, используется локальный режим" + try: + projects = normalize_items(api_client.ApiClient(settings).get_projects()) + for project in projects: + local_cache.cache_project(project) + return merge_projects(projects, local_projects), u"" + except Exception as exc: + logger.exception("Failed to load projects") + return local_projects, u"Сервер недоступен, используется локальный режим" + + +def choose_project(project): + if not project: + return config.load() + return config.set_current_project(project) + + +def create_project(payload): + settings = config.load() + if config.is_local_mode(settings): + result = local_cache.save_local_project(payload) + config.set_current_project(result) + return result, u"Проект создан локально" + try: + result = api_client.ApiClient(settings).create_project(payload) + if isinstance(result, dict): + local_cache.cache_project(result) + config.set_current_project(result) + return result, u"Проект создан" + except Exception as exc: + logger.exception("Failed to create project via API") + result = local_cache.save_local_project(payload, str(exc)) + config.set_current_project(result) + return result, u"Сервер недоступен, проект создан локально" + + +def require_project(settings=None): + settings = settings or config.load() + if not settings.get("CURRENT_PROJECT_ID"): + raise ValueError(u"Сначала выберите проект") + return settings + + +def sync_pending(project_id): + settings = config.load() + if config.is_local_mode(settings): + return 0 + client = api_client.ApiClient(settings) + sent = 0 + for item in local_cache.pending(project_id): + try: + payload = item.get("payload_json") + payload = json.loads(payload) if isinstance(payload, basestring) else payload + if item.get("action") == "create_task": + res = client.create_task(payload) + if isinstance(res, dict): + local_cache.cache_task(res) + elif item.get("action") == "patch_task": + client.patch_task(payload.get("id") or payload.get("temporary_id"), payload.get("project_id"), payload) + elif item.get("action") == "comment_task": + client.add_comment(payload.get("task_id"), payload.get("project_id"), payload.get("comment")) + local_cache.delete_pending(item.get("id")) + sent += 1 + except Exception as exc: + local_cache.mark_pending_error(item.get("id"), str(exc)) + return sent diff --git a/pyrevit.extension/lib/gip_tasks/revit_context.py b/pyrevit.extension/lib/gip_tasks/revit_context.py new file mode 100644 index 0000000..627f4cc --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/revit_context.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +try: + from Autodesk.Revit.DB import LocationPoint +except Exception: + LocationPoint = None + +MM_PER_FOOT = 304.8 + + +def xyz_to_dict(point): + if point is None: + return {} + return {"x": point.X * MM_PER_FOOT, "y": point.Y * MM_PER_FOOT, "z": point.Z * MM_PER_FOOT} + + +def active_view_info(doc): + view = doc.ActiveView + level = "" + try: + if view.GenLevel: + level = view.GenLevel.Name + except Exception: + pass + return {"id": view.Id.IntegerValue, "name": view.Name, "view_type": str(view.ViewType), "level": level} + + +def marker_info(marker): + family_name = "" + type_name = "" + try: + family_name = marker.Symbol.Family.Name + type_name = marker.Symbol.Name + except Exception: + pass + location = {} + try: + if marker.Location: + location = xyz_to_dict(marker.Location.Point) + except Exception: + pass + return { + "element_id": marker.Id.IntegerValue, + "unique_id": getattr(marker, "UniqueId", ""), + "family_name": family_name, + "type_name": type_name, + "location": location, + } + + +def collect(uidoc, marker=None): + doc = uidoc.Document + data = { + "document_title": doc.Title, + "document_path": getattr(doc, "PathName", ""), + "revit_username": doc.Application.Username, + "active_view": active_view_info(doc), + } + if marker is not None: + data["marker"] = marker_info(marker) + return data + diff --git a/pyrevit.extension/lib/gip_tasks/sync_service.py b/pyrevit.extension/lib/gip_tasks/sync_service.py new file mode 100644 index 0000000..08202b9 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/sync_service.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from . import api_client, local_cache, task_models + + +def poll_project(project_id): + if not project_id: + raise ValueError(u"Сначала выберите проект") + client = api_client.ApiClient() + since = local_cache.get_last_sync(project_id) + data = client.task_changes(project_id, since) + items = data.get("items") if isinstance(data, dict) else data + count = 0 + for task in items or []: + local_cache.cache_task(task) + count += 1 + local_cache.set_last_sync(project_id, task_models.now_iso()) + return count + diff --git a/pyrevit.extension/lib/gip_tasks/task_models.py b/pyrevit.extension/lib/gip_tasks/task_models.py new file mode 100644 index 0000000..fb1dde5 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/task_models.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +import uuid +from datetime import datetime + + +TASK_TYPES = [u"отверстие", u"проём", u"закладная", u"фундамент", u"площадка", u"коллизия", u"уточнение", u"замечание", u"прочее"] +MARKER_STATUSES = [u"Новое", u"Принято", u"Отменено"] +PRIORITIES = [u"низкий", u"обычный", u"высокий", u"срочный"] +DISCIPLINES = [u"КЖ", u"КМ", u"АР", u"ОВ", u"ВК", u"ЭОМ", u"СС", u"ТХ"] + + +def now_iso(): + return datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + + +def now_local_text(): + return datetime.now().strftime("%d.%m.%Y %H:%M") + + +def server_to_marker_status(status): + if status in ("cancelled", "returned", u"Отменено"): + return u"Отменено" + if status in ("in_progress", "review", "closed", u"Принято"): + return u"Принято" + return u"Новое" + + +def marker_to_server_status(status): + if status == u"Принято": + return "in_progress" + if status == u"Отменено": + return "cancelled" + return "new" + + +def new_project_payload(settings, form_data): + return { + "id": form_data.get("id") or "", + "company_id": settings.get("COMPANY_ID"), + "name": form_data.get("name") or "", + "code": form_data.get("code") or "", + "customer": form_data.get("customer") or "", + "address": form_data.get("address") or "", + "description": form_data.get("description") or "", + "created_at": form_data.get("created_at") or now_iso(), + "updated_at": now_iso(), + "is_local": bool(form_data.get("is_local", False)), + "is_active": bool(form_data.get("is_active", True)), + "is_archived": False, + } + + +def new_task_payload(settings, form_data, revit_context): + project_id = settings.get("CURRENT_PROJECT_ID") or form_data.get("project_id") or "" + if not project_id: + raise ValueError(u"Сначала выберите проект") + marker = (revit_context or {}).get("marker") or {} + view = (revit_context or {}).get("active_view") or {} + title = form_data.get("type") or u"Задание" + marker_status = form_data.get("marker_status") or u"Новое" + payload = { + "id": form_data.get("id") or "", + "company_id": settings.get("COMPANY_ID"), + "project_id": project_id, + "project_name": settings.get("CURRENT_PROJECT_NAME"), + "project_code": settings.get("CURRENT_PROJECT_CODE"), + "title": title, + "description": form_data.get("description") or "", + "comment": form_data.get("comment") or "", + "type": form_data.get("type") or u"прочее", + "status": marker_to_server_status(marker_status), + "marker_status": marker_status, + "priority": form_data.get("priority") or u"обычный", + "sender_user_id": settings.get("CURRENT_USER"), + "sender_discipline": form_data.get("sender_discipline") or settings.get("CURRENT_DISCIPLINE"), + "receiver_discipline": form_data.get("receiver_discipline") or "", + "due_date": form_data.get("due_date") or "", + "created_at": form_data.get("created_at") or now_iso(), + "updated_at": now_iso(), + "source_model_name": (revit_context or {}).get("document_title") or "", + "current_revit_file_path": (revit_context or {}).get("document_path") or "", + "marker_element_id": marker.get("element_id") or "", + "marker_unique_id": marker.get("unique_id") or "", + "marker_family_name": marker.get("family_name") or "", + "marker_coordinates": marker.get("location") or {}, + "level_name": view.get("level") or "", + "view_name": view.get("name") or "", + "view_id": view.get("id") or "", + "is_deleted": False, + } + if not payload["id"]: + payload["temporary_id"] = "local-task-" + str(uuid.uuid4()) + return payload + diff --git a/pyrevit.extension/lib/gip_tasks/task_service.py b/pyrevit.extension/lib/gip_tasks/task_service.py new file mode 100644 index 0000000..5f93715 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/task_service.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from . import api_client, config, local_cache, logger, task_models + + +def list_tasks(project_id, filters=None, use_server=True): + if not project_id: + raise ValueError(u"Сначала выберите проект") + settings = config.load() + if not use_server: + if config.is_local_mode(settings): + return local_cache.list_tasks(project_id), u"Локальный режим" + return local_cache.list_tasks(project_id), u"Показан локальный кэш. Нажмите «Обновить» для синхронизации" + if config.is_local_mode(settings): + return local_cache.list_tasks(project_id), u"Сервер не настроен, используется локальный режим" + try: + data = api_client.ApiClient(settings).list_tasks(project_id, filters or {}) + items = data.get("items") if isinstance(data, dict) else data + items = items or [] + for task in items: + local_cache.cache_task(task) + return items, u"" + except Exception: + logger.exception("Failed to load tasks") + return local_cache.list_tasks(project_id), u"Сервер недоступен, показан локальный кэш" + + +def create_task(payload): + settings = config.load() + if not payload.get("project_id"): + raise ValueError(u"Сначала выберите проект") + if config.is_local_mode(settings): + local_cache.cache_task(payload) + return payload, u"Задание сохранено локально" + try: + result = api_client.ApiClient(settings).create_task(payload) + if isinstance(result, dict): + payload.update(result) + local_cache.cache_task(payload) + return payload, u"Задание отправлено" + except Exception: + logger.exception("Failed to create task via API") + local_cache.cache_task(payload) + local_cache.enqueue("create_task", payload) + return payload, u"Сервер недоступен, задание сохранено локально" + + +def update_status(task, marker_status): + project_id = task.get("project_id") + task_id = task.get("id") or task.get("temporary_id") + updates = { + "marker_status": marker_status, + "status": task_models.marker_to_server_status(marker_status), + "updated_at": task_models.now_iso(), + } + settings = config.load() + if config.is_local_mode(settings) or not task.get("id"): + updated = local_cache.update_task(project_id, task_id, updates) + return updated or task, u"Статус сохранен локально" + try: + updated = api_client.ApiClient(settings).patch_task(task.get("id"), project_id, updates) + if isinstance(updated, dict): + local_cache.cache_task(updated) + return updated, u"Статус обновлен" + except Exception: + logger.exception("Failed to update status via API") + local_cache.enqueue("patch_task", dict(task, **updates)) + updated = local_cache.update_task(project_id, task_id, updates) + return updated or task, u"Сервер недоступен, статус сохранен локально" + + +def add_comment(task, comment): + project_id = task.get("project_id") + task_id = task.get("id") or task.get("temporary_id") + settings = config.load() + if config.is_local_mode(settings) or not task.get("id"): + updated = local_cache.add_comment(project_id, task_id, comment) + return updated or task, u"Комментарий сохранен локально" + try: + api_client.ApiClient(settings).add_comment(task.get("id"), project_id, comment) + except Exception: + logger.exception("Failed to send comment via API") + local_cache.enqueue("comment_task", {"project_id": project_id, "task_id": task.get("id"), "comment": comment}) + updated = local_cache.add_comment(project_id, task_id, comment) + return updated or task, u"Комментарий сохранен" diff --git a/pyrevit.extension/lib/gip_tasks/ui/__init__.py b/pyrevit.extension/lib/gip_tasks/ui/__init__.py new file mode 100644 index 0000000..633f866 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/ui/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- + diff --git a/pyrevit.extension/lib/gip_tasks/ui/create_project_window.py b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.py new file mode 100644 index 0000000..ddd2da0 --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import os +from pyrevit import forms +from gip_tasks import config +from gip_tasks.ui import theme + + +def xaml_path(name): + return os.path.join(config.extension_root(), "lib", "gip_tasks", "ui", name) + + +class CreateProjectWindow(forms.WPFWindow): + def __init__(self): + forms.WPFWindow.__init__(self, xaml_path("create_project_window.xaml")) + self.result = None + theme.apply_theme(self) + + def create_click(self, sender, args): + name = (self.NameBox.Text or "").strip() + if not name: + forms.alert(u"Укажите название проекта", title="GIP Tasks") + return + self.result = { + "name": name, + "is_active": True, + } + self.DialogResult = True + self.Close() + + def cancel_click(self, sender, args): + self.DialogResult = False + self.Close() diff --git a/pyrevit.extension/lib/gip_tasks/ui/create_project_window.xaml b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.xaml new file mode 100644 index 0000000..c87ebfa --- /dev/null +++ b/pyrevit.extension/lib/gip_tasks/ui/create_project_window.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + +