wiltech-labs-organization 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wiltech_labs_organization-1.0.0/.gitignore +220 -0
- wiltech_labs_organization-1.0.0/PKG-INFO +149 -0
- wiltech_labs_organization-1.0.0/README.md +130 -0
- wiltech_labs_organization-1.0.0/pyproject.toml +45 -0
- wiltech_labs_organization-1.0.0/src/wiltech_labs_organization/__init__.py +40 -0
- wiltech_labs_organization-1.0.0/src/wiltech_labs_organization/nodes/__init__.py +17 -0
- wiltech_labs_organization-1.0.0/src/wiltech_labs_organization/nodes/nodes.py +92 -0
- wiltech_labs_organization-1.0.0/src/wiltech_labs_organization/py.typed +0 -0
- wiltech_labs_organization-1.0.0/src/wiltech_labs_organization/structure/__init__.py +23 -0
- wiltech_labs_organization-1.0.0/src/wiltech_labs_organization/structure/rules.py +243 -0
- wiltech_labs_organization-1.0.0/tests/test_organization.py +222 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
/NEXT_STEPS11111.md
|
|
220
|
+
.idea/*
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: wiltech-labs-organization
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: An organisation's structure for Wiltech apps: org chart items in the shape @wiltech-labs/ngx-organization draws, and the rules for where organisations, entities, departments and jobs may sit.
|
|
5
|
+
Project-URL: Homepage, https://github.com/wilferraciolli/py-libraries/tree/main/packages/organization
|
|
6
|
+
Project-URL: Repository, https://github.com/wilferraciolli/py-libraries
|
|
7
|
+
Project-URL: Conventions, https://github.com/wilferraciolli/py-libraries/blob/main/docs/PYTHON_APP_CONVENTIONS.md
|
|
8
|
+
Author: Wiliam Ferraciolli
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: departments,fastapi,jobs,org-chart,organization
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Framework :: Pydantic :: 2
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Requires-Dist: pydantic>=2.7
|
|
17
|
+
Requires-Dist: wiltech-labs-rest>=1.0.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# wiltech-labs-organization
|
|
21
|
+
|
|
22
|
+
An organisation's structure for Wiltech FastAPI apps: the org chart's items in the shape
|
|
23
|
+
[`@wiltech-labs/ngx-organization`](https://www.npmjs.com/package/@wiltech-labs/ngx-organization)
|
|
24
|
+
draws, and the rules for where each part of the structure may sit. Apps keep the structure in
|
|
25
|
+
their own tables; this package says what's allowed and how to send it.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv add wiltech-labs-organization
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## The structure
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
ORG one, at the top
|
|
35
|
+
├── ORG_ENTITY under the ORG or a DEPARTMENT; never inside another ORG_ENTITY
|
|
36
|
+
│ └── DEPARTMENT under the ORG, an ORG_ENTITY or a DEPARTMENT, to any depth
|
|
37
|
+
│ ├── DEPARTMENT …
|
|
38
|
+
│ └── JOB under a DEPARTMENT only
|
|
39
|
+
│ occupancies: person A 2026-03-01 → 2026-08-31, person B 2026-09-01 → (open)
|
|
40
|
+
└── DEPARTMENT
|
|
41
|
+
└── ORG_ENTITY allowed: no ORG_ENTITY above it
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **One `ORG`** at the top, with no parent.
|
|
45
|
+
- **`ORG_ENTITY`** (a company or other part of the group) sits under the `ORG` or under a
|
|
46
|
+
`DEPARTMENT`. Once a line from the top has passed an `ORG_ENTITY`, no other `ORG_ENTITY` can
|
|
47
|
+
appear below it: at most one on any line.
|
|
48
|
+
- **`DEPARTMENT`** sits under the `ORG`, an `ORG_ENTITY` or another `DEPARTMENT`, with no limit on
|
|
49
|
+
depth. A department may name one of its jobs as its reporting job (`reporting_job_id`): whoever
|
|
50
|
+
holds it heads the department.
|
|
51
|
+
- **`JOB`** sits under a `DEPARTMENT`, never under the `ORG` or an `ORG_ENTITY`.
|
|
52
|
+
- **People are not nodes.** A person holding a job for a time is an `OrgOccupancy` (who, from,
|
|
53
|
+
until) carried on the job. A person can hold several jobs; whether a job can have more than one
|
|
54
|
+
holder at a time is the app's rule (`OrgOccupancy.overlaps` helps enforce one at a time).
|
|
55
|
+
|
|
56
|
+
## Checking a change
|
|
57
|
+
|
|
58
|
+
Load every node's id, type and parent (any object with `id`, `type`, `parent_id`: an `OrgNode`,
|
|
59
|
+
an `OrgNodeRef` or the app's own row) and ask before saving:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from wiltech_labs_organization import OrgNodeRef, OrgNodeType, OrgStructure, OrgStructureError
|
|
63
|
+
|
|
64
|
+
structure = OrgStructure([
|
|
65
|
+
OrgNodeRef(org.id, OrgNodeType.ORG),
|
|
66
|
+
*(OrgNodeRef(e.id, OrgNodeType.ORG_ENTITY, e.parent_id or org.id) for e in entities),
|
|
67
|
+
*(OrgNodeRef(d.id, OrgNodeType.DEPARTMENT, d.parent_id or org.id) for d in departments),
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
structure.check_add(OrgNodeType.ORG_ENTITY, parent_id=department_id) # a new node
|
|
72
|
+
structure.check_move(department_id, parent_id=entity_id) # a node and its branch
|
|
73
|
+
except OrgStructureError as error:
|
|
74
|
+
raise ConflictError(error.code, str(error)) # or answer in the app's own words, by error.code
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Error | `code` | When |
|
|
78
|
+
| --------------------- | ------------------- | -------------------------------------------------------------- |
|
|
79
|
+
| `InvalidParentError` | `INVALID_PARENT` | A type under a parent it may not sit under (a job under an entity) |
|
|
80
|
+
| `NestedEntityError` | `NESTED_ORG_ENTITY` | An entity inside another entity, directly or further down; moving a branch that holds an entity into one |
|
|
81
|
+
| `OrgCycleError` | `ORG_CYCLE` | A node moved under itself or under something below it |
|
|
82
|
+
| `OrgRootError` | `ORG_ROOT` | A second `ORG`, an `ORG` with a parent, or none at the top |
|
|
83
|
+
| `UnknownOrgNodeError` | `UNKNOWN_ORG_NODE` | A parent or node that isn't in the structure |
|
|
84
|
+
|
|
85
|
+
All are `OrgStructureError`, a `ValueError`. `structure.problems()` lists every broken rule in a
|
|
86
|
+
whole structure (for tests and integrity checks); `structure.check()` raises the first.
|
|
87
|
+
|
|
88
|
+
## Sending the org chart
|
|
89
|
+
|
|
90
|
+
`GET /org-chart` answers `_data.orgNodes`: a flat list of `OrgNode`, each pointing at its parent,
|
|
91
|
+
camelCase JSON (`parentId`, `reportingJobId`, `occupancies[].personId`, `startDate`, `endDate`).
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from wiltech_labs_organization import ORG_NODES_DATA_NAME, OrgChartResponse, OrgNode, org_chart_nodes
|
|
95
|
+
from wiltech_labs_rest import NoMetadata
|
|
96
|
+
|
|
97
|
+
nodes = [OrgNode(id=org.id, type="ORG", name=org.name), ...] # every node, with its links
|
|
98
|
+
items = org_chart_nodes(nodes, occupancies_by_job_id, as_of=today) # who holds each job today
|
|
99
|
+
return OrgChartResponse.of(ORG_NODES_DATA_NAME, items, NoMetadata())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`org_chart_nodes` attaches each job's occupancies (only those covering `as_of`, when given),
|
|
103
|
+
earliest first; jobs nobody holds get `[]`.
|
|
104
|
+
|
|
105
|
+
## Public API
|
|
106
|
+
|
|
107
|
+
| Name | What |
|
|
108
|
+
| -------------------------- | --------------------------------------------------------------------- |
|
|
109
|
+
| `OrgNodeType` | `ORG`, `ORG_ENTITY`, `DEPARTMENT`, `JOB` |
|
|
110
|
+
| `OrgNode` | One node (camelCase DTO with `links`) |
|
|
111
|
+
| `OrgOccupancy` | A person in a job from `start_date` to `end_date`; `covers`, `overlaps` |
|
|
112
|
+
| `OrgChartResponse` | `ApiResponse[list[OrgNode], NoMetadata]` |
|
|
113
|
+
| `ORG_NODES_DATA_NAME` | `"orgNodes"` |
|
|
114
|
+
| `org_chart_nodes` | Attaches occupancies to jobs for the chart |
|
|
115
|
+
| `OrgStructure` | `check_add`, `check_move`, `problems`, `check`, `ancestors`, `descendants`, `root` |
|
|
116
|
+
| `OrgNodeRef` | A node as the rules see it: `id`, `type`, `parent_id` |
|
|
117
|
+
| `ALLOWED_PARENTS` | Each type's allowed parent types |
|
|
118
|
+
| `OrgStructureError` and subclasses | See the table above |
|
|
119
|
+
|
|
120
|
+
## Publishing
|
|
121
|
+
|
|
122
|
+
Released to [TestPyPI](https://test.pypi.org/project/wiltech-labs-organization/) (rehearsal) and
|
|
123
|
+
[PyPI](https://pypi.org/project/wiltech-labs-organization/) (what apps install from). Account
|
|
124
|
+
setup, checking a TestPyPI release and fixing errors are covered in
|
|
125
|
+
[`docs/PUBLISHING.md`](https://github.com/wilferraciolli/py-libraries/blob/main/docs/PUBLISHING.md).
|
|
126
|
+
`wiltech-labs-rest` (>= 1.0.1) must be on PyPI first.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
cd packages/organization
|
|
130
|
+
|
|
131
|
+
# 1. Test, bump, build
|
|
132
|
+
uv run pytest
|
|
133
|
+
uv version --bump patch # skip for the first 1.0.0
|
|
134
|
+
rm -rf ../../dist && uv build # -> ../../dist/
|
|
135
|
+
|
|
136
|
+
# 2. TestPyPI (optional rehearsal), using a token from test.pypi.org
|
|
137
|
+
read -rsp "TestPyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
138
|
+
uv publish --index testpypi ../../dist/*
|
|
139
|
+
unset UV_PUBLISH_TOKEN
|
|
140
|
+
|
|
141
|
+
# 3. PyPI (the real thing), using a token from pypi.org
|
|
142
|
+
read -rsp "PyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
143
|
+
uv publish ../../dist/*
|
|
144
|
+
unset UV_PUBLISH_TOKEN
|
|
145
|
+
|
|
146
|
+
# 4. Commit and tag
|
|
147
|
+
git commit -am "wiltech-labs-organization <version>"
|
|
148
|
+
git tag organization-v<version> && git push && git push --tags
|
|
149
|
+
```
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# wiltech-labs-organization
|
|
2
|
+
|
|
3
|
+
An organisation's structure for Wiltech FastAPI apps: the org chart's items in the shape
|
|
4
|
+
[`@wiltech-labs/ngx-organization`](https://www.npmjs.com/package/@wiltech-labs/ngx-organization)
|
|
5
|
+
draws, and the rules for where each part of the structure may sit. Apps keep the structure in
|
|
6
|
+
their own tables; this package says what's allowed and how to send it.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
uv add wiltech-labs-organization
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The structure
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
ORG one, at the top
|
|
16
|
+
├── ORG_ENTITY under the ORG or a DEPARTMENT; never inside another ORG_ENTITY
|
|
17
|
+
│ └── DEPARTMENT under the ORG, an ORG_ENTITY or a DEPARTMENT, to any depth
|
|
18
|
+
│ ├── DEPARTMENT …
|
|
19
|
+
│ └── JOB under a DEPARTMENT only
|
|
20
|
+
│ occupancies: person A 2026-03-01 → 2026-08-31, person B 2026-09-01 → (open)
|
|
21
|
+
└── DEPARTMENT
|
|
22
|
+
└── ORG_ENTITY allowed: no ORG_ENTITY above it
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **One `ORG`** at the top, with no parent.
|
|
26
|
+
- **`ORG_ENTITY`** (a company or other part of the group) sits under the `ORG` or under a
|
|
27
|
+
`DEPARTMENT`. Once a line from the top has passed an `ORG_ENTITY`, no other `ORG_ENTITY` can
|
|
28
|
+
appear below it: at most one on any line.
|
|
29
|
+
- **`DEPARTMENT`** sits under the `ORG`, an `ORG_ENTITY` or another `DEPARTMENT`, with no limit on
|
|
30
|
+
depth. A department may name one of its jobs as its reporting job (`reporting_job_id`): whoever
|
|
31
|
+
holds it heads the department.
|
|
32
|
+
- **`JOB`** sits under a `DEPARTMENT`, never under the `ORG` or an `ORG_ENTITY`.
|
|
33
|
+
- **People are not nodes.** A person holding a job for a time is an `OrgOccupancy` (who, from,
|
|
34
|
+
until) carried on the job. A person can hold several jobs; whether a job can have more than one
|
|
35
|
+
holder at a time is the app's rule (`OrgOccupancy.overlaps` helps enforce one at a time).
|
|
36
|
+
|
|
37
|
+
## Checking a change
|
|
38
|
+
|
|
39
|
+
Load every node's id, type and parent (any object with `id`, `type`, `parent_id`: an `OrgNode`,
|
|
40
|
+
an `OrgNodeRef` or the app's own row) and ask before saving:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from wiltech_labs_organization import OrgNodeRef, OrgNodeType, OrgStructure, OrgStructureError
|
|
44
|
+
|
|
45
|
+
structure = OrgStructure([
|
|
46
|
+
OrgNodeRef(org.id, OrgNodeType.ORG),
|
|
47
|
+
*(OrgNodeRef(e.id, OrgNodeType.ORG_ENTITY, e.parent_id or org.id) for e in entities),
|
|
48
|
+
*(OrgNodeRef(d.id, OrgNodeType.DEPARTMENT, d.parent_id or org.id) for d in departments),
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
structure.check_add(OrgNodeType.ORG_ENTITY, parent_id=department_id) # a new node
|
|
53
|
+
structure.check_move(department_id, parent_id=entity_id) # a node and its branch
|
|
54
|
+
except OrgStructureError as error:
|
|
55
|
+
raise ConflictError(error.code, str(error)) # or answer in the app's own words, by error.code
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Error | `code` | When |
|
|
59
|
+
| --------------------- | ------------------- | -------------------------------------------------------------- |
|
|
60
|
+
| `InvalidParentError` | `INVALID_PARENT` | A type under a parent it may not sit under (a job under an entity) |
|
|
61
|
+
| `NestedEntityError` | `NESTED_ORG_ENTITY` | An entity inside another entity, directly or further down; moving a branch that holds an entity into one |
|
|
62
|
+
| `OrgCycleError` | `ORG_CYCLE` | A node moved under itself or under something below it |
|
|
63
|
+
| `OrgRootError` | `ORG_ROOT` | A second `ORG`, an `ORG` with a parent, or none at the top |
|
|
64
|
+
| `UnknownOrgNodeError` | `UNKNOWN_ORG_NODE` | A parent or node that isn't in the structure |
|
|
65
|
+
|
|
66
|
+
All are `OrgStructureError`, a `ValueError`. `structure.problems()` lists every broken rule in a
|
|
67
|
+
whole structure (for tests and integrity checks); `structure.check()` raises the first.
|
|
68
|
+
|
|
69
|
+
## Sending the org chart
|
|
70
|
+
|
|
71
|
+
`GET /org-chart` answers `_data.orgNodes`: a flat list of `OrgNode`, each pointing at its parent,
|
|
72
|
+
camelCase JSON (`parentId`, `reportingJobId`, `occupancies[].personId`, `startDate`, `endDate`).
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from wiltech_labs_organization import ORG_NODES_DATA_NAME, OrgChartResponse, OrgNode, org_chart_nodes
|
|
76
|
+
from wiltech_labs_rest import NoMetadata
|
|
77
|
+
|
|
78
|
+
nodes = [OrgNode(id=org.id, type="ORG", name=org.name), ...] # every node, with its links
|
|
79
|
+
items = org_chart_nodes(nodes, occupancies_by_job_id, as_of=today) # who holds each job today
|
|
80
|
+
return OrgChartResponse.of(ORG_NODES_DATA_NAME, items, NoMetadata())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`org_chart_nodes` attaches each job's occupancies (only those covering `as_of`, when given),
|
|
84
|
+
earliest first; jobs nobody holds get `[]`.
|
|
85
|
+
|
|
86
|
+
## Public API
|
|
87
|
+
|
|
88
|
+
| Name | What |
|
|
89
|
+
| -------------------------- | --------------------------------------------------------------------- |
|
|
90
|
+
| `OrgNodeType` | `ORG`, `ORG_ENTITY`, `DEPARTMENT`, `JOB` |
|
|
91
|
+
| `OrgNode` | One node (camelCase DTO with `links`) |
|
|
92
|
+
| `OrgOccupancy` | A person in a job from `start_date` to `end_date`; `covers`, `overlaps` |
|
|
93
|
+
| `OrgChartResponse` | `ApiResponse[list[OrgNode], NoMetadata]` |
|
|
94
|
+
| `ORG_NODES_DATA_NAME` | `"orgNodes"` |
|
|
95
|
+
| `org_chart_nodes` | Attaches occupancies to jobs for the chart |
|
|
96
|
+
| `OrgStructure` | `check_add`, `check_move`, `problems`, `check`, `ancestors`, `descendants`, `root` |
|
|
97
|
+
| `OrgNodeRef` | A node as the rules see it: `id`, `type`, `parent_id` |
|
|
98
|
+
| `ALLOWED_PARENTS` | Each type's allowed parent types |
|
|
99
|
+
| `OrgStructureError` and subclasses | See the table above |
|
|
100
|
+
|
|
101
|
+
## Publishing
|
|
102
|
+
|
|
103
|
+
Released to [TestPyPI](https://test.pypi.org/project/wiltech-labs-organization/) (rehearsal) and
|
|
104
|
+
[PyPI](https://pypi.org/project/wiltech-labs-organization/) (what apps install from). Account
|
|
105
|
+
setup, checking a TestPyPI release and fixing errors are covered in
|
|
106
|
+
[`docs/PUBLISHING.md`](https://github.com/wilferraciolli/py-libraries/blob/main/docs/PUBLISHING.md).
|
|
107
|
+
`wiltech-labs-rest` (>= 1.0.1) must be on PyPI first.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cd packages/organization
|
|
111
|
+
|
|
112
|
+
# 1. Test, bump, build
|
|
113
|
+
uv run pytest
|
|
114
|
+
uv version --bump patch # skip for the first 1.0.0
|
|
115
|
+
rm -rf ../../dist && uv build # -> ../../dist/
|
|
116
|
+
|
|
117
|
+
# 2. TestPyPI (optional rehearsal), using a token from test.pypi.org
|
|
118
|
+
read -rsp "TestPyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
119
|
+
uv publish --index testpypi ../../dist/*
|
|
120
|
+
unset UV_PUBLISH_TOKEN
|
|
121
|
+
|
|
122
|
+
# 3. PyPI (the real thing), using a token from pypi.org
|
|
123
|
+
read -rsp "PyPI token: " UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN && echo
|
|
124
|
+
uv publish ../../dist/*
|
|
125
|
+
unset UV_PUBLISH_TOKEN
|
|
126
|
+
|
|
127
|
+
# 4. Commit and tag
|
|
128
|
+
git commit -am "wiltech-labs-organization <version>"
|
|
129
|
+
git tag organization-v<version> && git push && git push --tags
|
|
130
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wiltech-labs-organization"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "An organisation's structure for Wiltech apps: org chart items in the shape @wiltech-labs/ngx-organization draws, and the rules for where organisations, entities, departments and jobs may sit."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
authors = [{ name = "Wiliam Ferraciolli" }]
|
|
9
|
+
keywords = ["organization", "org-chart", "departments", "jobs", "fastapi"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Framework :: FastAPI",
|
|
12
|
+
"Framework :: Pydantic :: 2",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"pydantic>=2.7",
|
|
18
|
+
"wiltech-labs-rest>=1.0.1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/wilferraciolli/py-libraries/tree/main/packages/organization"
|
|
23
|
+
Repository = "https://github.com/wilferraciolli/py-libraries"
|
|
24
|
+
Conventions = "https://github.com/wilferraciolli/py-libraries/blob/main/docs/PYTHON_APP_CONVENTIONS.md"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
wiltech-labs-rest = { workspace = true }
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
exclude = ["CLAUDE.md", ".pytest_cache"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/wiltech_labs_organization"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""An organisation's structure for Wiltech apps: the org chart's items in the shape
|
|
2
|
+
`@wiltech-labs/ngx-organization` draws, and the rules for where each node may sit."""
|
|
3
|
+
|
|
4
|
+
from wiltech_labs_organization.nodes import (
|
|
5
|
+
ORG_NODES_DATA_NAME,
|
|
6
|
+
OrgChartResponse,
|
|
7
|
+
OrgNode,
|
|
8
|
+
OrgNodeType,
|
|
9
|
+
OrgOccupancy,
|
|
10
|
+
org_chart_nodes,
|
|
11
|
+
)
|
|
12
|
+
from wiltech_labs_organization.structure import (
|
|
13
|
+
ALLOWED_PARENTS,
|
|
14
|
+
InvalidParentError,
|
|
15
|
+
NestedEntityError,
|
|
16
|
+
OrgCycleError,
|
|
17
|
+
OrgNodeRef,
|
|
18
|
+
OrgRootError,
|
|
19
|
+
OrgStructure,
|
|
20
|
+
OrgStructureError,
|
|
21
|
+
UnknownOrgNodeError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ALLOWED_PARENTS",
|
|
26
|
+
"InvalidParentError",
|
|
27
|
+
"NestedEntityError",
|
|
28
|
+
"ORG_NODES_DATA_NAME",
|
|
29
|
+
"OrgChartResponse",
|
|
30
|
+
"OrgCycleError",
|
|
31
|
+
"OrgNode",
|
|
32
|
+
"OrgNodeRef",
|
|
33
|
+
"OrgNodeType",
|
|
34
|
+
"OrgOccupancy",
|
|
35
|
+
"OrgRootError",
|
|
36
|
+
"OrgStructure",
|
|
37
|
+
"OrgStructureError",
|
|
38
|
+
"UnknownOrgNodeError",
|
|
39
|
+
"org_chart_nodes",
|
|
40
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from wiltech_labs_organization.nodes.nodes import (
|
|
2
|
+
ORG_NODES_DATA_NAME,
|
|
3
|
+
OrgChartResponse,
|
|
4
|
+
OrgNode,
|
|
5
|
+
OrgNodeType,
|
|
6
|
+
OrgOccupancy,
|
|
7
|
+
org_chart_nodes,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ORG_NODES_DATA_NAME",
|
|
12
|
+
"OrgChartResponse",
|
|
13
|
+
"OrgNode",
|
|
14
|
+
"OrgNodeType",
|
|
15
|
+
"OrgOccupancy",
|
|
16
|
+
"org_chart_nodes",
|
|
17
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The org chart's items: the flat list `@wiltech-labs/ngx-organization` draws, as the API sends it.
|
|
2
|
+
|
|
3
|
+
Four node types make the tree (`OrgNodeType`). A person holding a job is not a node: an
|
|
4
|
+
`OrgOccupancy` (who, from when, until when) travels on the job it fills.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import date
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Iterable, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from wiltech_labs_rest import ApiResponse, CamelLinkedResource, NoMetadata
|
|
12
|
+
|
|
13
|
+
# The `_data` name of the org chart response, which ngx-organization's `OrgChartStore` reads.
|
|
14
|
+
ORG_NODES_DATA_NAME = "orgNodes"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OrgNodeType(StrEnum):
|
|
18
|
+
"""What a node is. `OrgStructure` holds the rules for where each one may sit."""
|
|
19
|
+
|
|
20
|
+
ORG = "ORG"
|
|
21
|
+
ORG_ENTITY = "ORG_ENTITY"
|
|
22
|
+
DEPARTMENT = "DEPARTMENT"
|
|
23
|
+
JOB = "JOB"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OrgOccupancy(CamelLinkedResource):
|
|
27
|
+
"""A person holding a job from `start_date` to `end_date`, both included (no `end_date`: still
|
|
28
|
+
holding it). Not a node of the structure: the job carries it in `occupancies`.
|
|
29
|
+
|
|
30
|
+
`person_id` is the same across every job the person holds."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
person_id: str
|
|
34
|
+
name: str
|
|
35
|
+
start_date: date
|
|
36
|
+
end_date: Optional[date] = None
|
|
37
|
+
|
|
38
|
+
def covers(self, day: date) -> bool:
|
|
39
|
+
"""Whether the person holds the job on `day`."""
|
|
40
|
+
return self.start_date <= day and (self.end_date is None or day <= self.end_date)
|
|
41
|
+
|
|
42
|
+
def overlaps(self, start_date: date, end_date: Optional[date]) -> bool:
|
|
43
|
+
"""Whether this occupancy shares at least one day with `start_date`–`end_date` (no
|
|
44
|
+
`end_date`: open-ended). Apps that allow one person per job at a time refuse a new
|
|
45
|
+
occupancy that overlaps an existing one."""
|
|
46
|
+
return (end_date is None or self.start_date <= end_date) and (
|
|
47
|
+
self.end_date is None or start_date <= self.end_date
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OrgNode(CamelLinkedResource):
|
|
52
|
+
"""One node of the structure, pointing at its parent (`parent_id`, None only for the `ORG`).
|
|
53
|
+
|
|
54
|
+
- `reporting_job_id`: `DEPARTMENT` only, the `JOB` that heads it (its manager), if any. The
|
|
55
|
+
chart draws the two as one card.
|
|
56
|
+
- `occupancies`: `JOB` only, who holds it, earliest first. None on the other types.
|
|
57
|
+
- `links`: the actions the API allows on this node.
|
|
58
|
+
|
|
59
|
+
JSON is camelCase (`parentId`, `reportingJobId`, `personId`, `startDate`), the names
|
|
60
|
+
ngx-organization reads."""
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
type: OrgNodeType
|
|
64
|
+
name: str
|
|
65
|
+
parent_id: Optional[str] = None
|
|
66
|
+
description: Optional[str] = None
|
|
67
|
+
reporting_job_id: Optional[str] = None
|
|
68
|
+
occupancies: Optional[list[OrgOccupancy]] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
OrgChartResponse = ApiResponse[list[OrgNode], NoMetadata]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def org_chart_nodes(
|
|
75
|
+
nodes: Iterable[OrgNode],
|
|
76
|
+
occupancies: Mapping[str, Iterable[OrgOccupancy]],
|
|
77
|
+
*,
|
|
78
|
+
as_of: Optional[date] = None,
|
|
79
|
+
) -> list[OrgNode]:
|
|
80
|
+
"""`nodes` with each job's occupancies attached (`occupancies` maps a job's id to its own),
|
|
81
|
+
earliest first. With `as_of`, only the occupancies covering that day: who holds each job on
|
|
82
|
+
the chart's date. Jobs nobody holds get an empty list; other nodes keep `occupancies=None`.
|
|
83
|
+
The nodes come back in the order given."""
|
|
84
|
+
result: list[OrgNode] = []
|
|
85
|
+
for node in nodes:
|
|
86
|
+
if node.type != OrgNodeType.JOB:
|
|
87
|
+
result.append(node.model_copy(update={"occupancies": None}))
|
|
88
|
+
continue
|
|
89
|
+
held = [o for o in occupancies.get(node.id, ()) if as_of is None or o.covers(as_of)]
|
|
90
|
+
held.sort(key=lambda o: (o.start_date, o.name))
|
|
91
|
+
result.append(node.model_copy(update={"occupancies": held}))
|
|
92
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from wiltech_labs_organization.structure.rules import (
|
|
2
|
+
ALLOWED_PARENTS,
|
|
3
|
+
InvalidParentError,
|
|
4
|
+
NestedEntityError,
|
|
5
|
+
OrgCycleError,
|
|
6
|
+
OrgNodeRef,
|
|
7
|
+
OrgRootError,
|
|
8
|
+
OrgStructure,
|
|
9
|
+
OrgStructureError,
|
|
10
|
+
UnknownOrgNodeError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ALLOWED_PARENTS",
|
|
15
|
+
"InvalidParentError",
|
|
16
|
+
"NestedEntityError",
|
|
17
|
+
"OrgCycleError",
|
|
18
|
+
"OrgNodeRef",
|
|
19
|
+
"OrgRootError",
|
|
20
|
+
"OrgStructure",
|
|
21
|
+
"OrgStructureError",
|
|
22
|
+
"UnknownOrgNodeError",
|
|
23
|
+
]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Where each node may sit in an organisation's structure, checked before a change is saved.
|
|
2
|
+
|
|
3
|
+
The rules:
|
|
4
|
+
|
|
5
|
+
- one `ORG` at the top, with no parent;
|
|
6
|
+
- an `ORG_ENTITY` sits under the `ORG` or under a `DEPARTMENT`, never inside another
|
|
7
|
+
`ORG_ENTITY`: at most one on any line from the top down;
|
|
8
|
+
- a `DEPARTMENT` sits under the `ORG`, an `ORG_ENTITY` or another `DEPARTMENT`, to any depth;
|
|
9
|
+
- a `JOB` sits under a `DEPARTMENT` only, never under the `ORG` or an `ORG_ENTITY`.
|
|
10
|
+
|
|
11
|
+
People are not nodes: a person holding a job for a time is an `OrgOccupancy` on the job.
|
|
12
|
+
|
|
13
|
+
Apps keep the structure in their own tables. They load it into an `OrgStructure` (every node's
|
|
14
|
+
id, type and parent; a few hundred rows at most) and ask it before they save a new node or a move.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Iterable, Optional, Protocol
|
|
19
|
+
|
|
20
|
+
from wiltech_labs_organization.nodes.nodes import OrgNodeType
|
|
21
|
+
|
|
22
|
+
ALLOWED_PARENTS: dict[OrgNodeType, frozenset[OrgNodeType]] = {
|
|
23
|
+
OrgNodeType.ORG: frozenset(),
|
|
24
|
+
OrgNodeType.ORG_ENTITY: frozenset({OrgNodeType.ORG, OrgNodeType.DEPARTMENT}),
|
|
25
|
+
OrgNodeType.DEPARTMENT: frozenset({OrgNodeType.ORG, OrgNodeType.ORG_ENTITY, OrgNodeType.DEPARTMENT}),
|
|
26
|
+
OrgNodeType.JOB: frozenset({OrgNodeType.DEPARTMENT}),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OrgStructureError(ValueError):
|
|
31
|
+
"""A change that would break the structure rules. `code` tells the cases apart, so an app can
|
|
32
|
+
answer in its own words; the message is plain English."""
|
|
33
|
+
|
|
34
|
+
code = "ORG_STRUCTURE"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnknownOrgNodeError(OrgStructureError):
|
|
38
|
+
code = "UNKNOWN_ORG_NODE"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OrgRootError(OrgStructureError):
|
|
42
|
+
"""A second `ORG`, an `ORG` with a parent, or a structure without one."""
|
|
43
|
+
|
|
44
|
+
code = "ORG_ROOT"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InvalidParentError(OrgStructureError):
|
|
48
|
+
"""A node under a type it may not sit under: a job under the org or an entity, say."""
|
|
49
|
+
|
|
50
|
+
code = "INVALID_PARENT"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NestedEntityError(OrgStructureError):
|
|
54
|
+
"""An `ORG_ENTITY` inside another `ORG_ENTITY`, directly or further down."""
|
|
55
|
+
|
|
56
|
+
code = "NESTED_ORG_ENTITY"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OrgCycleError(OrgStructureError):
|
|
60
|
+
"""A node moved under itself or under something below it."""
|
|
61
|
+
|
|
62
|
+
code = "ORG_CYCLE"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_PLACES: dict[OrgNodeType, str] = {
|
|
66
|
+
OrgNodeType.ORG_ENTITY: "An org entity can only sit under the organisation or a department",
|
|
67
|
+
OrgNodeType.DEPARTMENT: "A department can only sit under the organisation, an org entity or another department",
|
|
68
|
+
OrgNodeType.JOB: "A job can only sit under a department",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OrgNodeLike(Protocol):
|
|
73
|
+
"""What `OrgStructure` reads from a node: an `OrgNode`, an `OrgNodeRef` or an app's own row."""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def id(self) -> str: ...
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def type(self) -> str: ...
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def parent_id(self) -> Optional[str]: ...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class OrgNodeRef:
|
|
87
|
+
"""A node as the rules see it: its id, type and parent."""
|
|
88
|
+
|
|
89
|
+
id: str
|
|
90
|
+
type: OrgNodeType
|
|
91
|
+
parent_id: Optional[str] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class OrgStructure:
|
|
95
|
+
"""An organisation's structure, to check a change before saving it (`check_add`,
|
|
96
|
+
`check_move`) or to check the whole of it (`problems`, `check`)."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, nodes: Iterable[OrgNodeLike]):
|
|
99
|
+
self._nodes: dict[str, OrgNodeRef] = {}
|
|
100
|
+
for node in nodes:
|
|
101
|
+
self._nodes[node.id] = OrgNodeRef(node.id, OrgNodeType(node.type), node.parent_id)
|
|
102
|
+
self._children: dict[str, list[str]] = {}
|
|
103
|
+
for ref in self._nodes.values():
|
|
104
|
+
if ref.parent_id is not None:
|
|
105
|
+
self._children.setdefault(ref.parent_id, []).append(ref.id)
|
|
106
|
+
|
|
107
|
+
def __contains__(self, node_id: object) -> bool:
|
|
108
|
+
return node_id in self._nodes
|
|
109
|
+
|
|
110
|
+
def __len__(self) -> int:
|
|
111
|
+
return len(self._nodes)
|
|
112
|
+
|
|
113
|
+
def get(self, node_id: Optional[str]) -> Optional[OrgNodeRef]:
|
|
114
|
+
return self._nodes.get(node_id) if node_id is not None else None
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def root(self) -> Optional[OrgNodeRef]:
|
|
118
|
+
"""The `ORG`, if there is exactly one."""
|
|
119
|
+
roots = [ref for ref in self._nodes.values() if ref.type == OrgNodeType.ORG]
|
|
120
|
+
return roots[0] if len(roots) == 1 else None
|
|
121
|
+
|
|
122
|
+
def ancestors(self, node_id: str) -> list[OrgNodeRef]:
|
|
123
|
+
"""The node's parent, its parent's parent and so on up to the top, nearest first. Stops at
|
|
124
|
+
an unknown parent or a loop."""
|
|
125
|
+
result: list[OrgNodeRef] = []
|
|
126
|
+
seen = {node_id}
|
|
127
|
+
current = self._nodes.get(node_id)
|
|
128
|
+
while current is not None and current.parent_id is not None and current.parent_id not in seen:
|
|
129
|
+
parent = self._nodes.get(current.parent_id)
|
|
130
|
+
if parent is None:
|
|
131
|
+
break
|
|
132
|
+
result.append(parent)
|
|
133
|
+
seen.add(parent.id)
|
|
134
|
+
current = parent
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
def descendants(self, node_id: str) -> list[OrgNodeRef]:
|
|
138
|
+
"""Everything below the node, at any depth."""
|
|
139
|
+
result: list[OrgNodeRef] = []
|
|
140
|
+
seen = {node_id}
|
|
141
|
+
pending = list(self._children.get(node_id, ()))
|
|
142
|
+
while pending:
|
|
143
|
+
child_id = pending.pop()
|
|
144
|
+
if child_id in seen:
|
|
145
|
+
continue
|
|
146
|
+
seen.add(child_id)
|
|
147
|
+
result.append(self._nodes[child_id])
|
|
148
|
+
pending.extend(self._children.get(child_id, ()))
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def _loops(self, node_id: str) -> bool:
|
|
152
|
+
"""Whether walking up from the node comes back round instead of reaching the top."""
|
|
153
|
+
seen = {node_id}
|
|
154
|
+
current = self._nodes.get(node_id)
|
|
155
|
+
while current is not None and current.parent_id is not None:
|
|
156
|
+
if current.parent_id in seen:
|
|
157
|
+
return True
|
|
158
|
+
seen.add(current.parent_id)
|
|
159
|
+
current = self._nodes.get(current.parent_id)
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
def _entity_at_or_above(self, node_id: str) -> bool:
|
|
163
|
+
node = self._nodes[node_id]
|
|
164
|
+
return node.type == OrgNodeType.ORG_ENTITY or any(
|
|
165
|
+
ref.type == OrgNodeType.ORG_ENTITY for ref in self.ancestors(node_id)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _entity_at_or_below(self, node_id: str) -> bool:
|
|
169
|
+
node = self._nodes[node_id]
|
|
170
|
+
return node.type == OrgNodeType.ORG_ENTITY or any(
|
|
171
|
+
ref.type == OrgNodeType.ORG_ENTITY for ref in self.descendants(node_id)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _parent(self, parent_id: Optional[str]) -> OrgNodeRef:
|
|
175
|
+
parent = self.get(parent_id)
|
|
176
|
+
if parent is None:
|
|
177
|
+
raise UnknownOrgNodeError("The parent isn't part of this organisation")
|
|
178
|
+
return parent
|
|
179
|
+
|
|
180
|
+
def check_add(self, node_type: OrgNodeType | str, parent_id: Optional[str]) -> None:
|
|
181
|
+
"""Raises an `OrgStructureError` if a new node of `node_type` may not sit under
|
|
182
|
+
`parent_id` (None only for the `ORG`)."""
|
|
183
|
+
node_type = OrgNodeType(node_type)
|
|
184
|
+
if node_type == OrgNodeType.ORG:
|
|
185
|
+
if parent_id is not None:
|
|
186
|
+
raise OrgRootError("The organisation sits at the top, under nothing")
|
|
187
|
+
if any(ref.type == OrgNodeType.ORG for ref in self._nodes.values()):
|
|
188
|
+
raise OrgRootError("There is already an organisation at the top")
|
|
189
|
+
return
|
|
190
|
+
parent = self._parent(parent_id)
|
|
191
|
+
if node_type == OrgNodeType.ORG_ENTITY and self._entity_at_or_above(parent.id):
|
|
192
|
+
raise NestedEntityError("An org entity can't sit inside another org entity")
|
|
193
|
+
if parent.type not in ALLOWED_PARENTS[node_type]:
|
|
194
|
+
raise InvalidParentError(_PLACES[node_type])
|
|
195
|
+
|
|
196
|
+
def check_move(self, node_id: str, parent_id: Optional[str]) -> None:
|
|
197
|
+
"""Raises an `OrgStructureError` if the node may not move under `parent_id`. Moving keeps
|
|
198
|
+
everything below the node with it, so an entity inside the moved branch counts too."""
|
|
199
|
+
node = self.get(node_id)
|
|
200
|
+
if node is None:
|
|
201
|
+
raise UnknownOrgNodeError("That isn't part of this organisation")
|
|
202
|
+
if node.type == OrgNodeType.ORG:
|
|
203
|
+
if parent_id is not None:
|
|
204
|
+
raise OrgRootError("The organisation sits at the top, under nothing")
|
|
205
|
+
return
|
|
206
|
+
parent = self._parent(parent_id)
|
|
207
|
+
if parent.id == node.id or parent.id in {ref.id for ref in self.descendants(node.id)}:
|
|
208
|
+
raise OrgCycleError("Nothing can sit under itself or under something below it")
|
|
209
|
+
if self._entity_at_or_below(node.id) and self._entity_at_or_above(parent.id):
|
|
210
|
+
raise NestedEntityError("An org entity can't sit inside another org entity")
|
|
211
|
+
if parent.type not in ALLOWED_PARENTS[node.type]:
|
|
212
|
+
raise InvalidParentError(_PLACES[node.type])
|
|
213
|
+
|
|
214
|
+
def problems(self) -> list[OrgStructureError]:
|
|
215
|
+
"""Every way the structure as a whole breaks the rules, for tests and integrity checks."""
|
|
216
|
+
found: list[OrgStructureError] = []
|
|
217
|
+
roots = [ref for ref in self._nodes.values() if ref.type == OrgNodeType.ORG]
|
|
218
|
+
if len(roots) != 1:
|
|
219
|
+
found.append(OrgRootError(f"Expected one organisation at the top, found {len(roots)}"))
|
|
220
|
+
for ref in self._nodes.values():
|
|
221
|
+
if ref.type == OrgNodeType.ORG:
|
|
222
|
+
if ref.parent_id is not None:
|
|
223
|
+
found.append(OrgRootError(f"The organisation {ref.id} has a parent"))
|
|
224
|
+
continue
|
|
225
|
+
parent = self.get(ref.parent_id)
|
|
226
|
+
if parent is None:
|
|
227
|
+
found.append(UnknownOrgNodeError(f"{ref.id}'s parent {ref.parent_id} isn't in the structure"))
|
|
228
|
+
continue
|
|
229
|
+
if self._loops(ref.id):
|
|
230
|
+
found.append(OrgCycleError(f"{ref.id} sits in a loop instead of under the organisation"))
|
|
231
|
+
continue
|
|
232
|
+
ancestors = self.ancestors(ref.id)
|
|
233
|
+
if ref.type == OrgNodeType.ORG_ENTITY and any(a.type == OrgNodeType.ORG_ENTITY for a in ancestors):
|
|
234
|
+
found.append(NestedEntityError(f"The org entity {ref.id} sits inside another org entity"))
|
|
235
|
+
if parent.type not in ALLOWED_PARENTS[ref.type]:
|
|
236
|
+
found.append(InvalidParentError(f"{ref.id}: {_PLACES[ref.type]}"))
|
|
237
|
+
return found
|
|
238
|
+
|
|
239
|
+
def check(self) -> None:
|
|
240
|
+
"""Raises the first of `problems()`, if any."""
|
|
241
|
+
found = self.problems()
|
|
242
|
+
if found:
|
|
243
|
+
raise found[0]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from wiltech_labs_organization import (
|
|
6
|
+
ORG_NODES_DATA_NAME,
|
|
7
|
+
InvalidParentError,
|
|
8
|
+
NestedEntityError,
|
|
9
|
+
OrgChartResponse,
|
|
10
|
+
OrgCycleError,
|
|
11
|
+
OrgNode,
|
|
12
|
+
OrgNodeRef,
|
|
13
|
+
OrgNodeType,
|
|
14
|
+
OrgOccupancy,
|
|
15
|
+
OrgRootError,
|
|
16
|
+
OrgStructure,
|
|
17
|
+
UnknownOrgNodeError,
|
|
18
|
+
org_chart_nodes,
|
|
19
|
+
)
|
|
20
|
+
from wiltech_labs_rest import NoMetadata
|
|
21
|
+
|
|
22
|
+
ORG, ENTITY, DEPARTMENT, JOB = OrgNodeType.ORG, OrgNodeType.ORG_ENTITY, OrgNodeType.DEPARTMENT, OrgNodeType.JOB
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def structure() -> OrgStructure:
|
|
26
|
+
"""org ─┬─ hotels (entity) ── front (department) ── reception (department) ── receptionist (job)
|
|
27
|
+
└─ group (department) ── finance (department)"""
|
|
28
|
+
return OrgStructure([
|
|
29
|
+
OrgNodeRef("org", ORG),
|
|
30
|
+
OrgNodeRef("hotels", ENTITY, "org"),
|
|
31
|
+
OrgNodeRef("front", DEPARTMENT, "hotels"),
|
|
32
|
+
OrgNodeRef("reception", DEPARTMENT, "front"),
|
|
33
|
+
OrgNodeRef("receptionist", JOB, "reception"),
|
|
34
|
+
OrgNodeRef("group", DEPARTMENT, "org"),
|
|
35
|
+
OrgNodeRef("finance", DEPARTMENT, "group"),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Adding
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("node_type, parent", [
|
|
43
|
+
(ENTITY, "org"), (ENTITY, "group"), (ENTITY, "finance"),
|
|
44
|
+
(DEPARTMENT, "org"), (DEPARTMENT, "hotels"), (DEPARTMENT, "reception"),
|
|
45
|
+
(JOB, "front"), (JOB, "finance"),
|
|
46
|
+
])
|
|
47
|
+
def test_allowed_places(node_type, parent):
|
|
48
|
+
structure().check_add(node_type, parent)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("parent", ["org", "hotels", "receptionist"])
|
|
52
|
+
def test_a_job_sits_under_a_department_only(parent):
|
|
53
|
+
with pytest.raises(InvalidParentError, match="only sit under a department"):
|
|
54
|
+
structure().check_add(JOB, parent)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("parent", ["hotels", "front", "reception"])
|
|
58
|
+
def test_no_entity_inside_an_entity_at_any_depth(parent):
|
|
59
|
+
with pytest.raises(NestedEntityError):
|
|
60
|
+
structure().check_add(ENTITY, parent)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_nothing_sits_under_a_job():
|
|
64
|
+
with pytest.raises(InvalidParentError):
|
|
65
|
+
structure().check_add(DEPARTMENT, "receptionist")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_one_organisation_at_the_top():
|
|
69
|
+
with pytest.raises(OrgRootError):
|
|
70
|
+
structure().check_add(ORG, None)
|
|
71
|
+
with pytest.raises(OrgRootError):
|
|
72
|
+
OrgStructure([]).check_add(ORG, "somewhere")
|
|
73
|
+
OrgStructure([]).check_add(ORG, None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_unknown_parent():
|
|
77
|
+
with pytest.raises(UnknownOrgNodeError):
|
|
78
|
+
structure().check_add(DEPARTMENT, "nowhere")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Moving
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_a_department_moves_anywhere_a_department_may_sit():
|
|
85
|
+
structure().check_move("finance", "front")
|
|
86
|
+
structure().check_move("group", "hotels")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_moving_takes_the_branch_along():
|
|
90
|
+
s = OrgStructure([
|
|
91
|
+
OrgNodeRef("org", ORG),
|
|
92
|
+
OrgNodeRef("hotels", ENTITY, "org"),
|
|
93
|
+
OrgNodeRef("group", DEPARTMENT, "org"),
|
|
94
|
+
OrgNodeRef("travel", ENTITY, "group"),
|
|
95
|
+
])
|
|
96
|
+
# "group" holds an entity, so it can't go inside "hotels" even though departments may.
|
|
97
|
+
with pytest.raises(NestedEntityError):
|
|
98
|
+
s.check_move("group", "hotels")
|
|
99
|
+
s.check_move("travel", "org")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_no_cycles():
|
|
103
|
+
with pytest.raises(OrgCycleError):
|
|
104
|
+
structure().check_move("front", "reception")
|
|
105
|
+
with pytest.raises(OrgCycleError):
|
|
106
|
+
structure().check_move("front", "front")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_an_entity_moves_under_a_department_outside_any_entity():
|
|
110
|
+
structure().check_move("hotels", "finance")
|
|
111
|
+
with pytest.raises(NestedEntityError):
|
|
112
|
+
OrgStructure([
|
|
113
|
+
OrgNodeRef("org", ORG),
|
|
114
|
+
OrgNodeRef("a", ENTITY, "org"),
|
|
115
|
+
OrgNodeRef("d", DEPARTMENT, "a"),
|
|
116
|
+
OrgNodeRef("b", ENTITY, "org"),
|
|
117
|
+
]).check_move("b", "d")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_a_job_moves_between_departments_only():
|
|
121
|
+
structure().check_move("receptionist", "finance")
|
|
122
|
+
with pytest.raises(InvalidParentError):
|
|
123
|
+
structure().check_move("receptionist", "hotels")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_the_organisation_never_moves():
|
|
127
|
+
with pytest.raises(OrgRootError):
|
|
128
|
+
structure().check_move("org", "group")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# The whole structure
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_valid_structure_has_no_problems():
|
|
135
|
+
assert structure().problems() == []
|
|
136
|
+
structure().check()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_problems_lists_every_broken_rule():
|
|
140
|
+
s = OrgStructure([
|
|
141
|
+
OrgNodeRef("org", ORG),
|
|
142
|
+
OrgNodeRef("a", ENTITY, "org"),
|
|
143
|
+
OrgNodeRef("b", ENTITY, "a"),
|
|
144
|
+
OrgNodeRef("j", JOB, "org"),
|
|
145
|
+
OrgNodeRef("x", DEPARTMENT, "y"),
|
|
146
|
+
OrgNodeRef("y", DEPARTMENT, "x"),
|
|
147
|
+
OrgNodeRef("self", DEPARTMENT, "self"),
|
|
148
|
+
OrgNodeRef("lost", DEPARTMENT, "gone"),
|
|
149
|
+
])
|
|
150
|
+
kinds = sorted(type(problem).__name__ for problem in s.problems())
|
|
151
|
+
assert kinds == [
|
|
152
|
+
"InvalidParentError", # b (an entity under an entity)
|
|
153
|
+
"InvalidParentError", # j (a job under the org)
|
|
154
|
+
"NestedEntityError", # b
|
|
155
|
+
"OrgCycleError", # x
|
|
156
|
+
"OrgCycleError", # y
|
|
157
|
+
"OrgCycleError", # self
|
|
158
|
+
"UnknownOrgNodeError", # lost
|
|
159
|
+
]
|
|
160
|
+
with pytest.raises(OrgRootError):
|
|
161
|
+
OrgStructure([OrgNodeRef("d", DEPARTMENT, None)]).check()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_ancestors_and_descendants():
|
|
165
|
+
s = structure()
|
|
166
|
+
assert [ref.id for ref in s.ancestors("receptionist")] == ["reception", "front", "hotels", "org"]
|
|
167
|
+
assert {ref.id for ref in s.descendants("hotels")} == {"front", "reception", "receptionist"}
|
|
168
|
+
assert s.root == OrgNodeRef("org", ORG)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_reads_any_node_with_an_id_type_and_parent():
|
|
172
|
+
nodes = [OrgNode(id="org", type=ORG, name="Harbour"), OrgNode(id="d", type="DEPARTMENT", name="Bar", parent_id="org")]
|
|
173
|
+
OrgStructure(nodes).check()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Occupancies and the chart's items
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def occupancy(start: date, end: date | None = None, name: str = "Eleni Georgiou") -> OrgOccupancy:
|
|
180
|
+
return OrgOccupancy(id=f"a-{start}", person_id="p1", name=name, start_date=start, end_date=end)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_an_occupancy_covers_its_days_inclusive():
|
|
184
|
+
held = occupancy(date(2026, 3, 1), date(2026, 3, 31))
|
|
185
|
+
assert held.covers(date(2026, 3, 1)) and held.covers(date(2026, 3, 31))
|
|
186
|
+
assert not held.covers(date(2026, 4, 1))
|
|
187
|
+
assert occupancy(date(2026, 3, 1)).covers(date(2030, 1, 1))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_overlaps():
|
|
191
|
+
held = occupancy(date(2026, 3, 1), date(2026, 3, 31))
|
|
192
|
+
assert held.overlaps(date(2026, 3, 31), None)
|
|
193
|
+
assert not held.overlaps(date(2026, 4, 1), None)
|
|
194
|
+
assert occupancy(date(2026, 3, 1)).overlaps(date(2020, 1, 1), date(2026, 3, 1))
|
|
195
|
+
assert not occupancy(date(2026, 3, 1)).overlaps(date(2020, 1, 1), date(2026, 2, 28))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_org_chart_nodes_attach_each_jobs_occupancies():
|
|
199
|
+
nodes = [
|
|
200
|
+
OrgNode(id="org", type=ORG, name="Harbour"),
|
|
201
|
+
OrgNode(id="front", type=DEPARTMENT, name="Front office", parent_id="org", reporting_job_id="manager"),
|
|
202
|
+
OrgNode(id="manager", type=JOB, name="Front office manager", parent_id="front"),
|
|
203
|
+
OrgNode(id="night", type=JOB, name="Night auditor", parent_id="front"),
|
|
204
|
+
]
|
|
205
|
+
past, current = occupancy(date(2025, 1, 1), date(2025, 12, 31), "Andreas"), occupancy(date(2026, 1, 1))
|
|
206
|
+
items = org_chart_nodes(nodes, {"manager": [current, past]}, as_of=date(2026, 11, 15))
|
|
207
|
+
assert [item.occupancies for item in items] == [None, None, [current], []]
|
|
208
|
+
assert org_chart_nodes(nodes, {"manager": [current, past]})[2].occupancies == [past, current]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_the_json_is_the_shape_ngx_organization_reads():
|
|
212
|
+
node = OrgNode(
|
|
213
|
+
id="manager", type=JOB, name="Front office manager", parent_id="front",
|
|
214
|
+
occupancies=[occupancy(date(2026, 1, 1))],
|
|
215
|
+
)
|
|
216
|
+
body = OrgChartResponse.of(ORG_NODES_DATA_NAME, [node], NoMetadata()).model_dump(mode="json", by_alias=True)
|
|
217
|
+
item = body["_data"]["orgNodes"][0]
|
|
218
|
+
assert item["type"] == "JOB" and item["parentId"] == "front"
|
|
219
|
+
assert item["occupancies"][0] == {
|
|
220
|
+
"id": "a-2026-01-01", "personId": "p1", "name": "Eleni Georgiou", "startDate": "2026-01-01",
|
|
221
|
+
"endDate": None, "links": {},
|
|
222
|
+
}
|