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 0000000..3f9af65 Binary files /dev/null and b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/icon.png differ diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/script.py b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.pushbutton/script.py new file mode 100644 index 0000000..a4a9f2f --- /dev/null +++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/SendTask.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_create_task(__revit__.ActiveUIDocument) diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/bundle.yaml b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/bundle.yaml new file mode 100644 index 0000000..5e3c6c8 --- /dev/null +++ b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/bundle.yaml @@ -0,0 +1,3 @@ +title: "Журнал\nзаданий" +tooltip: "Открыть журнал заданий между разделами" +author: CPSK diff --git a/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/icon.png b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/icon.png new file mode 100644 index 0000000..63d6563 Binary files /dev/null and b/pyrevit.extension/CPSK.tab/99_Beta.panel/Beta.pulldown/TaskJournal.pushbutton/icon.png differ 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 @@ + + + + + + + + + + + + + + + +