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.
@@ -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
@@ -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
+ }