signalsafe-platform-core 0.4.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.
- signalsafe_platform_core-0.4.0/LICENSE +21 -0
- signalsafe_platform_core-0.4.0/MANIFEST.in +2 -0
- signalsafe_platform_core-0.4.0/PKG-INFO +18 -0
- signalsafe_platform_core-0.4.0/README.md +5 -0
- signalsafe_platform_core-0.4.0/docs/README.md +41 -0
- signalsafe_platform_core-0.4.0/pyproject.toml +28 -0
- signalsafe_platform_core-0.4.0/setup.cfg +4 -0
- signalsafe_platform_core-0.4.0/src/platform_core/__init__.py +1 -0
- signalsafe_platform_core-0.4.0/src/platform_core/enums.py +32 -0
- signalsafe_platform_core-0.4.0/src/platform_core/models/__init__.py +1 -0
- signalsafe_platform_core-0.4.0/src/platform_core/models/base.py +18 -0
- signalsafe_platform_core-0.4.0/src/platform_core/models/contracts.py +123 -0
- signalsafe_platform_core-0.4.0/src/platform_core/models/defaults.py +56 -0
- signalsafe_platform_core-0.4.0/src/platform_core/py.typed +0 -0
- signalsafe_platform_core-0.4.0/src/platform_core/repositories/__init__.py +1 -0
- signalsafe_platform_core-0.4.0/src/platform_core/repositories/departments.py +55 -0
- signalsafe_platform_core-0.4.0/src/platform_core/repositories/profiles.py +295 -0
- signalsafe_platform_core-0.4.0/src/platform_core/repositories/signup.py +52 -0
- signalsafe_platform_core-0.4.0/src/platform_core/repositories/users.py +89 -0
- signalsafe_platform_core-0.4.0/src/platform_core/services/__init__.py +1 -0
- signalsafe_platform_core-0.4.0/src/platform_core/services/base.py +17 -0
- signalsafe_platform_core-0.4.0/src/platform_core/services/departments.py +63 -0
- signalsafe_platform_core-0.4.0/src/platform_core/services/invitations.py +59 -0
- signalsafe_platform_core-0.4.0/src/platform_core/services/profiles.py +55 -0
- signalsafe_platform_core-0.4.0/src/platform_core/services/signup.py +112 -0
- signalsafe_platform_core-0.4.0/src/platform_core/services/users.py +35 -0
- signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/PKG-INFO +18 -0
- signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/SOURCES.txt +29 -0
- signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/dependency_links.txt +1 -0
- signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/requires.txt +3 -0
- signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Josh Martin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
9
|
+
of the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: signalsafe-platform-core
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Shared platform-core domain services and repositories
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: <4,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: servicephilosophy<0.2,>=0.1.2
|
|
10
|
+
Requires-Dist: sqlphilosophy<0.4,>=0.3.4
|
|
11
|
+
Requires-Dist: sqlalchemy<3,>=2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# platform-core Python
|
|
15
|
+
|
|
16
|
+
See [Python models and services](docs/README.md). Python 0.4.0 is the Company-free release approved on 2026-10-21; TypeScript remains 0.1.0. Existing hosts stay on their prior compatible artifacts until tenant-bound session adoption.
|
|
17
|
+
|
|
18
|
+
Coordinated candidate dependencies: servicePhilosophy >=0.1.2,<0.2 and SQLPhilosophy >=0.3.4,<0.4. Build tooling requires patched setuptools >=84.2.1,<85. These unpublished minima are tested with exact local wheels before approval; fresh registry-only frozen checks remain required after upstream publication.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# platform-core Python
|
|
2
|
+
|
|
3
|
+
See [Python models and services](docs/README.md). Python 0.4.0 is the Company-free release approved on 2026-10-21; TypeScript remains 0.1.0. Existing hosts stay on their prior compatible artifacts until tenant-bound session adoption.
|
|
4
|
+
|
|
5
|
+
Coordinated candidate dependencies: servicePhilosophy >=0.1.2,<0.2 and SQLPhilosophy >=0.3.4,<0.4. Build tooling requires patched setuptools >=84.2.1,<85. These unpublished minima are tested with exact local wheels before approval; fresh registry-only frozen checks remain required after upstream publication.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Platform models and services
|
|
2
|
+
|
|
3
|
+
Python 0.4.0 is an unpublished breaking candidate. It owns four Company-free defaults:
|
|
4
|
+
User, Profile, Department and SignupInvitation. Company models, company repositories and
|
|
5
|
+
company services have moved out of this portable package. No compatibility aliases remain.
|
|
6
|
+
TypeScript remains independently versioned at 0.1.0 and its form contracts are unchanged.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from platform_core.models.defaults import Department, Profile
|
|
10
|
+
from platform_core.repositories.departments import DepartmentRepository
|
|
11
|
+
from platform_core.repositories.profiles import ProfileRepository
|
|
12
|
+
from platform_core.services.departments import DepartmentService
|
|
13
|
+
|
|
14
|
+
service = DepartmentService(
|
|
15
|
+
DepartmentRepository(Department, session), ProfileRepository(Profile, session)
|
|
16
|
+
)
|
|
17
|
+
department = service.create_department(name="Operations")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The host authenticates/authorizes the actor and supplies a tenant-bound session. All
|
|
21
|
+
repositories in a service must share it. Services neither commit nor send notifications.
|
|
22
|
+
Local profile IDs, roles, account/deletion status, deletion actors and department/user
|
|
23
|
+
relationships remain. Repositories no longer accept Company arguments: use get_active_for_user,
|
|
24
|
+
get_user_profile_entity, create_user_profile, list_active_ids and local department methods.
|
|
25
|
+
Missing/deleted rows remain unavailable. Usernames have local database uniqueness.
|
|
26
|
+
|
|
27
|
+
`SignupService` takes invitation, user and profile repositories plus a password-hash provider.
|
|
28
|
+
`complete_signup(token=..., username=..., password=..., area=[ProfileArea.EMPLOYEE])` returns
|
|
29
|
+
`(user_id, invitation, profile)`. The host must authorize the explicit roles. OWNER is reserved
|
|
30
|
+
for authorized provisioning; an explicitly empty role set grants no role. Expiry, token locking,
|
|
31
|
+
denial ordering, invitation reuse and caller-owned rollback remain. Customer signup never
|
|
32
|
+
creates a Company. New profiles use kind=user; preserve roles/history when transforming legacy
|
|
33
|
+
company_user values during extraction. Site provisioning belongs to DeliveryPlus administration.
|
|
34
|
+
|
|
35
|
+
Field contracts compose onto a host declarative base; PlatformBase supplies usable standalone
|
|
36
|
+
mappings. These are new customer schemas, not migrations for an existing shared database.
|
|
37
|
+
Existing hosts must retain the previous 0.3.0 artifact until they adopt tenant-bound
|
|
38
|
+
sessions/authentication. Never remove row-level scope from a shared-database runtime in isolation.
|
|
39
|
+
|
|
40
|
+
Run Ruff, strict mypy and all tests against explicitly disposable PostgreSQL; build clean
|
|
41
|
+
wheel/sdist artifacts and run scripts/check_python_artifact.py before repinning a host.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=84.2.1,<85", "wheel>=0.46,<1"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
[project]
|
|
5
|
+
name = "signalsafe-platform-core"
|
|
6
|
+
version = "0.4.0"
|
|
7
|
+
description = "Shared platform-core domain services and repositories"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12,<4"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = ["servicephilosophy>=0.1.2,<0.2", "sqlphilosophy>=0.3.4,<0.4", "sqlalchemy>=2,<3"]
|
|
12
|
+
[dependency-groups]
|
|
13
|
+
dev = ["build>=1.6.1,<2", "mypy>=1.17,<2", "pytest>=8.3,<10", "psycopg2-binary>=2.9,<3", "ruff>=0.12,<1"]
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
where = ["src"]
|
|
16
|
+
[tool.setuptools.package-data]
|
|
17
|
+
platform_core = ["py.typed"]
|
|
18
|
+
[tool.mypy]
|
|
19
|
+
strict = true
|
|
20
|
+
python_version = "3.12"
|
|
21
|
+
files = ["src", "tests"]
|
|
22
|
+
[tool.ruff]
|
|
23
|
+
target-version = "py312"
|
|
24
|
+
line-length = 90
|
|
25
|
+
[tool.ruff.lint]
|
|
26
|
+
select = ["E", "F", "I", "ANN", "ARG", "B", "C4", "PIE", "RET", "RUF", "SIM", "UP"]
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared platform-core APIs; import from explicit defining modules."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Owned persisted domain values; wire values remain stable."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum, StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StringEnum(StrEnum):
|
|
7
|
+
"""Retain established Enum display formatting while using strict string values."""
|
|
8
|
+
|
|
9
|
+
def __str__(self) -> str:
|
|
10
|
+
return Enum.__str__(self)
|
|
11
|
+
|
|
12
|
+
def __format__(self, format_spec: str) -> str:
|
|
13
|
+
return Enum.__format__(self, format_spec)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RecipientStatus(StringEnum):
|
|
17
|
+
ACTIVE = "active"
|
|
18
|
+
INACTIVE = "inactive"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProfileArea(StringEnum):
|
|
22
|
+
OWNER = "owner"
|
|
23
|
+
AUDITOR = "auditor"
|
|
24
|
+
CONTENT_EDITOR = "content_editor"
|
|
25
|
+
MANAGER = "manager"
|
|
26
|
+
EMPLOYEE = "employee"
|
|
27
|
+
AGENT = "agent"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProfileKind(StringEnum):
|
|
31
|
+
USER = "user"
|
|
32
|
+
INVITE = "invite"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Owned domain fields and default mapped models."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Default registry and typed abstract field contracts."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import MetaData
|
|
4
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PlatformContract(DeclarativeBase):
|
|
8
|
+
"""Unmapped contract registry; consumers choose their own concrete registry."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlatformBase(DeclarativeBase):
|
|
12
|
+
"""Default platform persistence registry."""
|
|
13
|
+
|
|
14
|
+
metadata = MetaData(
|
|
15
|
+
naming_convention={
|
|
16
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s"
|
|
17
|
+
}
|
|
18
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Package-owned scalar model definitions; hosts add integration relationships."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import (
|
|
6
|
+
BigInteger,
|
|
7
|
+
Boolean,
|
|
8
|
+
DateTime,
|
|
9
|
+
ForeignKey,
|
|
10
|
+
String,
|
|
11
|
+
)
|
|
12
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
13
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
14
|
+
from sqlphilosophy.audit.model import (
|
|
15
|
+
CreatedTimestampModel,
|
|
16
|
+
SoftDeleteModel,
|
|
17
|
+
SoftDeleteTimestampModel,
|
|
18
|
+
)
|
|
19
|
+
from sqlphilosophy.types import JSONValue
|
|
20
|
+
|
|
21
|
+
from platform_core.models.base import PlatformContract
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DepartmentModel(SoftDeleteTimestampModel, PlatformContract):
|
|
25
|
+
__abstract__ = True
|
|
26
|
+
|
|
27
|
+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
28
|
+
|
|
29
|
+
deleted_by_id: Mapped[int | None] = mapped_column(
|
|
30
|
+
BigInteger, ForeignKey("management_profile.id"), nullable=True
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
name: Mapped[str] = mapped_column(String(120))
|
|
34
|
+
|
|
35
|
+
external_id: Mapped[str] = mapped_column(String(120))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProfileModel(SoftDeleteModel, PlatformContract):
|
|
39
|
+
__abstract__ = True
|
|
40
|
+
|
|
41
|
+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
42
|
+
|
|
43
|
+
deleted_by_id: Mapped[int | None] = mapped_column(
|
|
44
|
+
BigInteger, ForeignKey("management_profile.id"), nullable=True
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
kind: Mapped[str] = mapped_column(String(20))
|
|
48
|
+
|
|
49
|
+
user_id: Mapped[int | None] = mapped_column(
|
|
50
|
+
BigInteger, ForeignKey("management_user.id"), nullable=True
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
department_id: Mapped[int | None] = mapped_column(
|
|
54
|
+
BigInteger, ForeignKey("management_department.id"), nullable=True
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
title: Mapped[str] = mapped_column(String(120))
|
|
58
|
+
|
|
59
|
+
status: Mapped[str] = mapped_column(String(10))
|
|
60
|
+
|
|
61
|
+
area: Mapped[JSONValue | None] = mapped_column(JSONB, nullable=True)
|
|
62
|
+
|
|
63
|
+
email: Mapped[str] = mapped_column(String(254))
|
|
64
|
+
|
|
65
|
+
first_name: Mapped[str] = mapped_column(String(150))
|
|
66
|
+
|
|
67
|
+
last_name: Mapped[str] = mapped_column(String(150))
|
|
68
|
+
|
|
69
|
+
token: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
70
|
+
|
|
71
|
+
expiration: Mapped[datetime | None] = mapped_column(
|
|
72
|
+
DateTime(timezone=True), nullable=True
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SignupInvitationModel(CreatedTimestampModel, PlatformContract):
|
|
77
|
+
__abstract__ = True
|
|
78
|
+
|
|
79
|
+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
80
|
+
|
|
81
|
+
email: Mapped[str] = mapped_column(String(254))
|
|
82
|
+
|
|
83
|
+
first_name: Mapped[str] = mapped_column(String(150))
|
|
84
|
+
|
|
85
|
+
last_name: Mapped[str] = mapped_column(String(150))
|
|
86
|
+
|
|
87
|
+
token: Mapped[str] = mapped_column(String(64))
|
|
88
|
+
|
|
89
|
+
expiration: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
90
|
+
|
|
91
|
+
completed_at: Mapped[datetime | None] = mapped_column(
|
|
92
|
+
DateTime(timezone=True), nullable=True
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UserModel(PlatformContract):
|
|
97
|
+
__abstract__ = True
|
|
98
|
+
|
|
99
|
+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
100
|
+
|
|
101
|
+
password: Mapped[str] = mapped_column(String(128))
|
|
102
|
+
|
|
103
|
+
last_login: Mapped[datetime | None] = mapped_column(
|
|
104
|
+
DateTime(timezone=True), nullable=True
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
is_superuser: Mapped[bool] = mapped_column(Boolean)
|
|
108
|
+
|
|
109
|
+
username: Mapped[str] = mapped_column(String(150), unique=True)
|
|
110
|
+
|
|
111
|
+
first_name: Mapped[str] = mapped_column(String(150))
|
|
112
|
+
|
|
113
|
+
last_name: Mapped[str] = mapped_column(String(150))
|
|
114
|
+
|
|
115
|
+
email: Mapped[str] = mapped_column(String(254))
|
|
116
|
+
|
|
117
|
+
is_staff: Mapped[bool] = mapped_column(Boolean)
|
|
118
|
+
|
|
119
|
+
is_active: Mapped[bool] = mapped_column(Boolean)
|
|
120
|
+
|
|
121
|
+
date_joined: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
122
|
+
|
|
123
|
+
phone_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Ready-to-use domain models on the default package registry."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import BigInteger, ForeignKey
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
5
|
+
|
|
6
|
+
from platform_core.models.base import PlatformBase
|
|
7
|
+
from platform_core.models.contracts import (
|
|
8
|
+
DepartmentModel,
|
|
9
|
+
ProfileModel,
|
|
10
|
+
SignupInvitationModel,
|
|
11
|
+
UserModel,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Department(PlatformBase, DepartmentModel):
|
|
16
|
+
__tablename__ = "management_department"
|
|
17
|
+
|
|
18
|
+
deleted_by_id: Mapped[int | None] = mapped_column(
|
|
19
|
+
BigInteger, ForeignKey("management_profile.id", use_alter=True), nullable=True
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
profiles: Mapped[list["Profile"]] = relationship(
|
|
23
|
+
"Profile", back_populates="department", foreign_keys="[Profile.department_id]"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
deleted_by_profile: Mapped["Profile | None"] = relationship(
|
|
27
|
+
"Profile", foreign_keys="[Department.deleted_by_id]"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Profile(PlatformBase, ProfileModel):
|
|
32
|
+
__tablename__ = "management_profile"
|
|
33
|
+
|
|
34
|
+
deleted_by_profile: Mapped["Profile | None"] = relationship(
|
|
35
|
+
"Profile", foreign_keys="[Profile.deleted_by_id]", remote_side="[Profile.id]"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
user: Mapped["User | None"] = relationship(
|
|
39
|
+
"User", back_populates="profiles", foreign_keys="[Profile.user_id]"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
department: Mapped["Department | None"] = relationship(
|
|
43
|
+
"Department", back_populates="profiles", foreign_keys="[Profile.department_id]"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SignupInvitation(PlatformBase, SignupInvitationModel):
|
|
48
|
+
__tablename__ = "management_signupinvite"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class User(PlatformBase, UserModel):
|
|
52
|
+
__tablename__ = "management_user"
|
|
53
|
+
|
|
54
|
+
profiles: Mapped[list["Profile"]] = relationship(
|
|
55
|
+
"Profile", back_populates="user", foreign_keys="[Profile.user_id]"
|
|
56
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain persistence; import explicit model-bound repositories."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Local department persistence; the host selects the session and owns commits."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import bindparam
|
|
6
|
+
from sqlphilosophy.audit.listener import soft_delete
|
|
7
|
+
from sqlphilosophy.sql import row_int, row_opt_str
|
|
8
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
9
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
10
|
+
|
|
11
|
+
from platform_core.models.contracts import DepartmentModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DepartmentRepository[T: DepartmentModel](BaseRepository[T, RepositoryFactory]):
|
|
15
|
+
def count_active(self) -> int:
|
|
16
|
+
return self.count_scoped(criteria=(self.model.deleted_on.is_(None),))
|
|
17
|
+
|
|
18
|
+
def get_id_by_name(self, *, name: str) -> int | None:
|
|
19
|
+
row = self.find_row(
|
|
20
|
+
criteria=(self.model.name == name, self.model.deleted_on.is_(None)),
|
|
21
|
+
columns=(self.model.id,),
|
|
22
|
+
)
|
|
23
|
+
return row_int(row, "id") if row is not None else None
|
|
24
|
+
|
|
25
|
+
def names_by_ids(self, *, department_ids: list[int]) -> dict[int, str]:
|
|
26
|
+
if not department_ids:
|
|
27
|
+
return {}
|
|
28
|
+
rows = (
|
|
29
|
+
self.statement()
|
|
30
|
+
.select_columns(self.model.id, self.model.name)
|
|
31
|
+
.where(
|
|
32
|
+
self.model.deleted_on.is_(None),
|
|
33
|
+
self.model.id.in_(bindparam("dept_ids", expanding=True)),
|
|
34
|
+
)
|
|
35
|
+
.with_params({"dept_ids": list(department_ids)})
|
|
36
|
+
.mappings()
|
|
37
|
+
.all()
|
|
38
|
+
)
|
|
39
|
+
return {row_int(row, "id"): row_opt_str(row, "name") or "" for row in rows}
|
|
40
|
+
|
|
41
|
+
def get_active(self, department_id: int) -> T | None:
|
|
42
|
+
department = self.get_by_id(department_id)
|
|
43
|
+
if department is None or department.deleted_on is not None:
|
|
44
|
+
return None
|
|
45
|
+
return department
|
|
46
|
+
|
|
47
|
+
def create_department(self, *, name: str, external_id: str = "") -> T:
|
|
48
|
+
now = datetime.now(UTC)
|
|
49
|
+
return self.create(
|
|
50
|
+
name=name, external_id=external_id, created_on=now, updated_on=now
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def soft_delete(self, department_id: int) -> None:
|
|
54
|
+
soft_delete(self.get(department_id))
|
|
55
|
+
self._session.flush()
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Profile persistence over owned model contracts; no commits."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import bindparam, func, literal
|
|
8
|
+
from sqlphilosophy.audit.listener import soft_delete
|
|
9
|
+
from sqlphilosophy.sql import row_int
|
|
10
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
11
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
12
|
+
|
|
13
|
+
from platform_core.enums import ProfileKind, RecipientStatus
|
|
14
|
+
from platform_core.models.contracts import ProfileModel
|
|
15
|
+
|
|
16
|
+
_USER_PROFILE_WRITABLE = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"department_id",
|
|
19
|
+
"title",
|
|
20
|
+
"status",
|
|
21
|
+
"area",
|
|
22
|
+
"email",
|
|
23
|
+
"first_name",
|
|
24
|
+
"last_name",
|
|
25
|
+
"deleted_on",
|
|
26
|
+
"deleted_by_id",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_INVITE_PROFILE_WRITABLE = frozenset(
|
|
31
|
+
{"email", "first_name", "last_name", "status", "area"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProfileRepository[T: ProfileModel](BaseRepository[T, RepositoryFactory]):
|
|
36
|
+
def user_has_profile(self, *, user_id: int) -> bool:
|
|
37
|
+
return self.get_active_for_user(user_id=user_id) is not None
|
|
38
|
+
|
|
39
|
+
def list_active_ids(self, *, profile_ids: list[int]) -> list[int]:
|
|
40
|
+
from sqlalchemy import bindparam
|
|
41
|
+
|
|
42
|
+
rows = (
|
|
43
|
+
self.statement()
|
|
44
|
+
.select_columns(self.model.id)
|
|
45
|
+
.where(
|
|
46
|
+
self.model.id.in_(bindparam("profile_ids", expanding=True)),
|
|
47
|
+
self.model.deleted_on.is_(None),
|
|
48
|
+
)
|
|
49
|
+
.with_params({"profile_ids": list(profile_ids)})
|
|
50
|
+
.mappings()
|
|
51
|
+
.all()
|
|
52
|
+
)
|
|
53
|
+
return [row_int(row, "id") for row in rows]
|
|
54
|
+
|
|
55
|
+
def get_id_for_user(self, *, user_id: int) -> int | None:
|
|
56
|
+
row = (
|
|
57
|
+
self.statement()
|
|
58
|
+
.select_columns(self.model.id)
|
|
59
|
+
.where(self.model.user_id == user_id)
|
|
60
|
+
.limit(1)
|
|
61
|
+
.mappings()
|
|
62
|
+
.first()
|
|
63
|
+
)
|
|
64
|
+
if row is None:
|
|
65
|
+
return None
|
|
66
|
+
return row_int(row, "id")
|
|
67
|
+
|
|
68
|
+
def get_active_for_user(self, *, user_id: int) -> T | None:
|
|
69
|
+
return self.find_entity(
|
|
70
|
+
criteria=(
|
|
71
|
+
self.model.user_id == user_id,
|
|
72
|
+
self.model.deleted_on.is_(None),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def list_ids_for_user(self, user_id: int) -> list[int]:
|
|
77
|
+
rows = (
|
|
78
|
+
self.statement()
|
|
79
|
+
.select_columns(self.model.id)
|
|
80
|
+
.where(self.model.user_id == user_id)
|
|
81
|
+
.mappings()
|
|
82
|
+
.all()
|
|
83
|
+
)
|
|
84
|
+
return [row_int(row, "id") for row in rows]
|
|
85
|
+
|
|
86
|
+
def delete_by_ids(self, profile_ids: Sequence[int]) -> None:
|
|
87
|
+
if not profile_ids:
|
|
88
|
+
return
|
|
89
|
+
self.delete_many(list(profile_ids))
|
|
90
|
+
|
|
91
|
+
def count_active(self) -> int:
|
|
92
|
+
return (
|
|
93
|
+
self.statement()
|
|
94
|
+
.select_entity()
|
|
95
|
+
.where(self.model.deleted_on.is_(None))
|
|
96
|
+
.count()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def invite_email_exists(
|
|
100
|
+
self, *, email: str, exclude_profile_id: int | None = None
|
|
101
|
+
) -> bool:
|
|
102
|
+
criteria = [
|
|
103
|
+
self.model.user_id.is_(None),
|
|
104
|
+
self.model.deleted_on.is_(None),
|
|
105
|
+
func.lower(self.model.email) == func.lower(email),
|
|
106
|
+
]
|
|
107
|
+
if exclude_profile_id is not None:
|
|
108
|
+
criteria.append(self.model.id != exclude_profile_id)
|
|
109
|
+
return (
|
|
110
|
+
self.statement()
|
|
111
|
+
.select_columns(literal(1))
|
|
112
|
+
.where(*criteria)
|
|
113
|
+
.limit(1)
|
|
114
|
+
.mappings()
|
|
115
|
+
.first()
|
|
116
|
+
is not None
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def count_invites(self) -> int:
|
|
120
|
+
return (
|
|
121
|
+
self.statement()
|
|
122
|
+
.select_entity()
|
|
123
|
+
.where(
|
|
124
|
+
self.model.user_id.is_(None),
|
|
125
|
+
self.model.deleted_on.is_(None),
|
|
126
|
+
)
|
|
127
|
+
.count()
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def create_user_profile(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
user_id: int,
|
|
134
|
+
department_id: int | None,
|
|
135
|
+
title: str,
|
|
136
|
+
area: list[str],
|
|
137
|
+
email: str,
|
|
138
|
+
first_name: str,
|
|
139
|
+
last_name: str,
|
|
140
|
+
) -> int:
|
|
141
|
+
profile = self.create(
|
|
142
|
+
kind=ProfileKind.USER.value,
|
|
143
|
+
user_id=user_id,
|
|
144
|
+
department_id=department_id,
|
|
145
|
+
title=title,
|
|
146
|
+
status=RecipientStatus.ACTIVE.value,
|
|
147
|
+
area=list(area),
|
|
148
|
+
email=email,
|
|
149
|
+
first_name=first_name,
|
|
150
|
+
last_name=last_name,
|
|
151
|
+
token=None,
|
|
152
|
+
expiration=None,
|
|
153
|
+
deleted_on=None,
|
|
154
|
+
deleted_by_id=None,
|
|
155
|
+
)
|
|
156
|
+
return int(profile.id)
|
|
157
|
+
|
|
158
|
+
def update_user_profile_fields(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
profile_id: int,
|
|
162
|
+
department_id: int | None,
|
|
163
|
+
title: str,
|
|
164
|
+
area: list[str],
|
|
165
|
+
email: str,
|
|
166
|
+
first_name: str,
|
|
167
|
+
last_name: str,
|
|
168
|
+
) -> None:
|
|
169
|
+
self.update_partial(
|
|
170
|
+
profile_id,
|
|
171
|
+
{
|
|
172
|
+
"department_id": department_id,
|
|
173
|
+
"title": title,
|
|
174
|
+
"status": RecipientStatus.ACTIVE.value,
|
|
175
|
+
"area": list(area),
|
|
176
|
+
"email": email,
|
|
177
|
+
"first_name": first_name,
|
|
178
|
+
"last_name": last_name,
|
|
179
|
+
"deleted_on": None,
|
|
180
|
+
"deleted_by_id": None,
|
|
181
|
+
},
|
|
182
|
+
_USER_PROFILE_WRITABLE,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def create_invite(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
email: str,
|
|
189
|
+
first_name: str,
|
|
190
|
+
last_name: str,
|
|
191
|
+
status: str,
|
|
192
|
+
area: list[str],
|
|
193
|
+
token: str,
|
|
194
|
+
expiration: datetime,
|
|
195
|
+
) -> int:
|
|
196
|
+
profile = self.create(
|
|
197
|
+
kind=ProfileKind.INVITE.value,
|
|
198
|
+
user_id=None,
|
|
199
|
+
email=email,
|
|
200
|
+
first_name=first_name,
|
|
201
|
+
last_name=last_name,
|
|
202
|
+
status=status,
|
|
203
|
+
area=list(area),
|
|
204
|
+
token=token,
|
|
205
|
+
expiration=expiration,
|
|
206
|
+
title="",
|
|
207
|
+
)
|
|
208
|
+
return int(profile.id)
|
|
209
|
+
|
|
210
|
+
def update_invite_fields(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
profile_id: int,
|
|
214
|
+
email: str,
|
|
215
|
+
first_name: str,
|
|
216
|
+
last_name: str,
|
|
217
|
+
status: str,
|
|
218
|
+
area: list[str],
|
|
219
|
+
) -> None:
|
|
220
|
+
if self.first(id=profile_id) is None:
|
|
221
|
+
return
|
|
222
|
+
self.update_partial(
|
|
223
|
+
profile_id,
|
|
224
|
+
{
|
|
225
|
+
"email": email,
|
|
226
|
+
"first_name": first_name,
|
|
227
|
+
"last_name": last_name,
|
|
228
|
+
"status": status,
|
|
229
|
+
"area": list(area),
|
|
230
|
+
},
|
|
231
|
+
_INVITE_PROFILE_WRITABLE,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def clear_department(self, *, profile_id: int) -> None:
|
|
235
|
+
if self.first(id=profile_id) is None:
|
|
236
|
+
return
|
|
237
|
+
self.update_partial(
|
|
238
|
+
profile_id, {"department_id": None}, frozenset({"department_id"})
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def update_area(self, *, profile_id: int, area: list[str]) -> None:
|
|
242
|
+
if self.first(id=profile_id) is None:
|
|
243
|
+
return
|
|
244
|
+
self.update_partial(
|
|
245
|
+
profile_id,
|
|
246
|
+
{"area": list(area)},
|
|
247
|
+
frozenset({"area"}),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def assign_department(self, *, profile_ids: list[int], department_id: int) -> int:
|
|
251
|
+
return self.update_where(
|
|
252
|
+
criteria=[
|
|
253
|
+
self.model.id.in_(bindparam("pids", expanding=True)),
|
|
254
|
+
self.model.deleted_on.is_(None),
|
|
255
|
+
],
|
|
256
|
+
values={"department_id": department_id},
|
|
257
|
+
params={"pids": list(profile_ids)},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def parse_area(value: object) -> list[str]:
|
|
262
|
+
if value is None:
|
|
263
|
+
return []
|
|
264
|
+
if isinstance(value, list):
|
|
265
|
+
return [str(v) for v in value]
|
|
266
|
+
if isinstance(value, str):
|
|
267
|
+
try:
|
|
268
|
+
parsed = json.loads(value)
|
|
269
|
+
if isinstance(parsed, list):
|
|
270
|
+
return [str(v) for v in parsed]
|
|
271
|
+
except json.JSONDecodeError:
|
|
272
|
+
return []
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
def get_user_profile_entity(self, *, profile_id: int) -> T | None:
|
|
276
|
+
return self.find_entity(
|
|
277
|
+
criteria=(
|
|
278
|
+
self.model.id == profile_id,
|
|
279
|
+
self.model.user_id.is_not(None),
|
|
280
|
+
self.model.deleted_on.is_(None),
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def get_invite_entity(self, *, profile_id: int) -> T | None:
|
|
285
|
+
return self.find_entity(
|
|
286
|
+
criteria=(
|
|
287
|
+
self.model.id == profile_id,
|
|
288
|
+
self.model.user_id.is_(None),
|
|
289
|
+
self.model.deleted_on.is_(None),
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def soft_delete(self, *, profile_id: int, actor_profile_id: int | None) -> None:
|
|
294
|
+
soft_delete(self.get(profile_id), actor=actor_profile_id)
|
|
295
|
+
self._session.flush()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Signup invitation persistence, with caller-owned locking and transactions."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
6
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
7
|
+
|
|
8
|
+
from platform_core.models.contracts import SignupInvitationModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SignupInvitationRepository[T: SignupInvitationModel](
|
|
12
|
+
BaseRepository[T, RepositoryFactory]
|
|
13
|
+
):
|
|
14
|
+
def find_active_by_email(self, email: str) -> T | None:
|
|
15
|
+
return self.find_entity(
|
|
16
|
+
criteria=(self.model.email == email, self.model.completed_at.is_(None))
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def delete_for_email(self, email: str) -> None:
|
|
20
|
+
self.delete_where(criteria=[self.model.email == email])
|
|
21
|
+
|
|
22
|
+
def create_invite(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
email: str,
|
|
26
|
+
first_name: str,
|
|
27
|
+
last_name: str,
|
|
28
|
+
token: str,
|
|
29
|
+
expiration: datetime,
|
|
30
|
+
created_on: datetime,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.create(
|
|
33
|
+
email=email,
|
|
34
|
+
first_name=first_name,
|
|
35
|
+
last_name=last_name,
|
|
36
|
+
token=token,
|
|
37
|
+
expiration=expiration,
|
|
38
|
+
created_on=created_on,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def find_pending_by_token(self, token: str) -> T | None:
|
|
42
|
+
return self.find_entity(
|
|
43
|
+
criteria=(self.model.token == token, self.model.completed_at.is_(None)),
|
|
44
|
+
lock=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def mark_completed(self, token: str, *, completed_at: datetime) -> None:
|
|
48
|
+
invite = self.first(token=token)
|
|
49
|
+
if invite is not None:
|
|
50
|
+
self.update_partial(
|
|
51
|
+
invite.id, {"completed_at": completed_at}, frozenset({"completed_at"})
|
|
52
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Portable users persistence bound to host models and columns; no commits."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sqlphilosophy.sql import row_int
|
|
6
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
7
|
+
from sqlphilosophy.sync.repository import BaseRepository
|
|
8
|
+
|
|
9
|
+
from platform_core.models.contracts import UserModel
|
|
10
|
+
|
|
11
|
+
_PASSWORD_WRITABLE = frozenset({"password"})
|
|
12
|
+
|
|
13
|
+
_SEED_WRITABLE = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"username",
|
|
16
|
+
"email",
|
|
17
|
+
"password",
|
|
18
|
+
"first_name",
|
|
19
|
+
"last_name",
|
|
20
|
+
"is_staff",
|
|
21
|
+
"is_superuser",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserRepository[T: UserModel](BaseRepository[T, RepositoryFactory]):
|
|
27
|
+
def get_id_by_username(self, username: str) -> int | None:
|
|
28
|
+
row = self.find_row(
|
|
29
|
+
criteria=(self.model.username == username,), columns=(self.model.id,)
|
|
30
|
+
)
|
|
31
|
+
return row_int(row, "id") if row is not None else None
|
|
32
|
+
|
|
33
|
+
def get_id_by_email(self, email: str) -> int | None:
|
|
34
|
+
user = self.first(email=email)
|
|
35
|
+
if user is None:
|
|
36
|
+
return None
|
|
37
|
+
return int(user.id)
|
|
38
|
+
|
|
39
|
+
def set_password_hash(self, user_id: int, hashed_password: str) -> None:
|
|
40
|
+
self.update_partial(user_id, {"password": hashed_password}, _PASSWORD_WRITABLE)
|
|
41
|
+
|
|
42
|
+
def upsert_seed_user(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
username: str,
|
|
46
|
+
email: str,
|
|
47
|
+
password_hash: str,
|
|
48
|
+
first_name: str,
|
|
49
|
+
last_name: str,
|
|
50
|
+
is_staff: bool,
|
|
51
|
+
is_superuser: bool,
|
|
52
|
+
date_joined: datetime,
|
|
53
|
+
) -> tuple[int, bool]:
|
|
54
|
+
user_id = self.get_id_by_username(username)
|
|
55
|
+
if user_id is None:
|
|
56
|
+
user = self.create(
|
|
57
|
+
username=username,
|
|
58
|
+
password=password_hash,
|
|
59
|
+
first_name=first_name,
|
|
60
|
+
last_name=last_name,
|
|
61
|
+
email=email,
|
|
62
|
+
is_superuser=is_superuser,
|
|
63
|
+
is_staff=is_staff,
|
|
64
|
+
is_active=True,
|
|
65
|
+
date_joined=date_joined,
|
|
66
|
+
last_login=None,
|
|
67
|
+
)
|
|
68
|
+
return int(user.id), True
|
|
69
|
+
self.update_partial(
|
|
70
|
+
user_id,
|
|
71
|
+
{
|
|
72
|
+
"email": email,
|
|
73
|
+
"password": password_hash,
|
|
74
|
+
"first_name": first_name,
|
|
75
|
+
"last_name": last_name,
|
|
76
|
+
"is_staff": is_staff,
|
|
77
|
+
"is_superuser": is_superuser,
|
|
78
|
+
},
|
|
79
|
+
_SEED_WRITABLE,
|
|
80
|
+
)
|
|
81
|
+
return user_id, False
|
|
82
|
+
|
|
83
|
+
def delete_by_usernames(self, usernames: tuple[str, ...] | list[str]) -> None:
|
|
84
|
+
if not usernames:
|
|
85
|
+
return
|
|
86
|
+
self.delete_where(criteria=[self.model.username.in_(usernames)])
|
|
87
|
+
|
|
88
|
+
def flush_pending_writes(self) -> None:
|
|
89
|
+
self._session.flush()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain services; import from explicit defining modules."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Overridable identity/profile/invitation services on servicePhilosophy."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from servicephilosophy import ServiceRepository
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlatformService[FactoryT](ServiceRepository[FactoryT]):
|
|
9
|
+
"""Shared service foundation and invitation expiry; hosts may override policy."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def invitation_expired(expiration: datetime | None, *, now: datetime) -> bool:
|
|
13
|
+
if expiration is None:
|
|
14
|
+
return True
|
|
15
|
+
if expiration.tzinfo is None:
|
|
16
|
+
expiration = expiration.replace(tzinfo=UTC)
|
|
17
|
+
return now >= expiration
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Local department lifecycle over repositories sharing the host transaction."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
6
|
+
|
|
7
|
+
from platform_core.models.contracts import DepartmentModel, ProfileModel
|
|
8
|
+
from platform_core.repositories.departments import DepartmentRepository
|
|
9
|
+
from platform_core.repositories.profiles import ProfileRepository
|
|
10
|
+
from platform_core.services.base import PlatformService
|
|
11
|
+
|
|
12
|
+
DEPARTMENT_WRITABLE_FIELDS = frozenset({"name", "external_id"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DepartmentService[D: DepartmentModel, P: ProfileModel](
|
|
16
|
+
PlatformService[RepositoryFactory]
|
|
17
|
+
):
|
|
18
|
+
def __init__(
|
|
19
|
+
self, repository: DepartmentRepository[D], profiles: ProfileRepository[P]
|
|
20
|
+
) -> None:
|
|
21
|
+
if repository.session is not profiles.session:
|
|
22
|
+
raise ValueError("Department repositories must share the same session")
|
|
23
|
+
super().__init__(repository.maybe_factory)
|
|
24
|
+
self.repository = repository
|
|
25
|
+
self.profiles = profiles
|
|
26
|
+
|
|
27
|
+
def get_department(self, *, department_id: int) -> D | None:
|
|
28
|
+
return self.repository.get_active(department_id)
|
|
29
|
+
|
|
30
|
+
def create_department(self, *, name: str, external_id: str = "") -> D:
|
|
31
|
+
created = self.repository.create_department(name=name, external_id=external_id)
|
|
32
|
+
department = self.get_department(department_id=created.id)
|
|
33
|
+
if department is None:
|
|
34
|
+
raise RuntimeError("department row missing after insert")
|
|
35
|
+
return department
|
|
36
|
+
|
|
37
|
+
def update_department(
|
|
38
|
+
self, *, department_id: int, patch: Mapping[str, str]
|
|
39
|
+
) -> D | None:
|
|
40
|
+
if self.get_department(department_id=department_id) is None:
|
|
41
|
+
return None
|
|
42
|
+
updates = {key: patch[key] for key in DEPARTMENT_WRITABLE_FIELDS if key in patch}
|
|
43
|
+
if updates:
|
|
44
|
+
self.repository.update_partial(
|
|
45
|
+
department_id, updates, DEPARTMENT_WRITABLE_FIELDS
|
|
46
|
+
)
|
|
47
|
+
return self.get_department(department_id=department_id)
|
|
48
|
+
|
|
49
|
+
def delete_department(self, *, department_id: int) -> bool:
|
|
50
|
+
department = self.repository.get_by_id(department_id)
|
|
51
|
+
if department is None:
|
|
52
|
+
return False
|
|
53
|
+
if department.deleted_on is not None:
|
|
54
|
+
return True
|
|
55
|
+
self.repository.soft_delete(department_id)
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def assign_department(self, *, department_id: int, profile_ids: list[int]) -> int:
|
|
59
|
+
if not profile_ids or self.repository.get_active(department_id) is None:
|
|
60
|
+
return 0
|
|
61
|
+
return self.profiles.assign_department(
|
|
62
|
+
department_id=department_id, profile_ids=profile_ids
|
|
63
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Local invitation writes over an owned profile repository."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
7
|
+
|
|
8
|
+
from platform_core.models.contracts import ProfileModel
|
|
9
|
+
from platform_core.repositories.profiles import ProfileRepository
|
|
10
|
+
from platform_core.services.base import PlatformService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class InvitationFields:
|
|
15
|
+
email: str
|
|
16
|
+
first_name: str
|
|
17
|
+
last_name: str
|
|
18
|
+
status: str
|
|
19
|
+
area: list[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvitationService[T: ProfileModel](PlatformService[RepositoryFactory]):
|
|
23
|
+
def __init__(self, repository: ProfileRepository[T]) -> None:
|
|
24
|
+
super().__init__(repository.maybe_factory)
|
|
25
|
+
self.repository = repository
|
|
26
|
+
|
|
27
|
+
def create_invitation(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
fields: InvitationFields,
|
|
31
|
+
token: str,
|
|
32
|
+
expiration: datetime,
|
|
33
|
+
) -> T:
|
|
34
|
+
profile_id = self.repository.create_invite(
|
|
35
|
+
email=fields.email,
|
|
36
|
+
first_name=fields.first_name,
|
|
37
|
+
last_name=fields.last_name,
|
|
38
|
+
status=fields.status,
|
|
39
|
+
area=fields.area,
|
|
40
|
+
token=token,
|
|
41
|
+
expiration=expiration,
|
|
42
|
+
)
|
|
43
|
+
invite = self.repository.get_invite_entity(profile_id=profile_id)
|
|
44
|
+
if invite is None:
|
|
45
|
+
raise RuntimeError("invite row missing after insert")
|
|
46
|
+
return invite
|
|
47
|
+
|
|
48
|
+
def update_invitation(self, *, profile_id: int, fields: InvitationFields) -> T | None:
|
|
49
|
+
if self.repository.get_invite_entity(profile_id=profile_id) is None:
|
|
50
|
+
return None
|
|
51
|
+
self.repository.update_invite_fields(
|
|
52
|
+
profile_id=profile_id,
|
|
53
|
+
email=fields.email,
|
|
54
|
+
first_name=fields.first_name,
|
|
55
|
+
last_name=fields.last_name,
|
|
56
|
+
status=fields.status,
|
|
57
|
+
area=fields.area,
|
|
58
|
+
)
|
|
59
|
+
return self.repository.get_invite_entity(profile_id=profile_id)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Profile editing and deletion over an owned repository."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
6
|
+
|
|
7
|
+
from platform_core.models.contracts import ProfileModel
|
|
8
|
+
from platform_core.repositories.profiles import ProfileRepository
|
|
9
|
+
from platform_core.services.base import PlatformService
|
|
10
|
+
|
|
11
|
+
PROFILE_WRITABLE_FIELDS = frozenset({"status", "title", "department_id"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ProfilePatch:
|
|
16
|
+
area: list[str] | None = None
|
|
17
|
+
status: str | None = None
|
|
18
|
+
department_id: int | None = None
|
|
19
|
+
title: str | None = None
|
|
20
|
+
clear_department: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProfileService[T: ProfileModel](PlatformService[RepositoryFactory]):
|
|
24
|
+
def __init__(self, repository: ProfileRepository[T]) -> None:
|
|
25
|
+
super().__init__(repository.maybe_factory)
|
|
26
|
+
self.repository = repository
|
|
27
|
+
|
|
28
|
+
def update_profile(self, *, profile_id: int, patch: ProfilePatch) -> T | None:
|
|
29
|
+
if self.repository.get_user_profile_entity(profile_id=profile_id) is None:
|
|
30
|
+
return None
|
|
31
|
+
if patch.clear_department:
|
|
32
|
+
self.repository.clear_department(profile_id=profile_id)
|
|
33
|
+
fields: dict[str, str | int] = {}
|
|
34
|
+
if patch.status is not None:
|
|
35
|
+
fields["status"] = patch.status
|
|
36
|
+
if patch.title is not None:
|
|
37
|
+
fields["title"] = patch.title
|
|
38
|
+
if patch.department_id is not None and not patch.clear_department:
|
|
39
|
+
fields["department_id"] = patch.department_id
|
|
40
|
+
if fields:
|
|
41
|
+
self.repository.update_partial(profile_id, fields, PROFILE_WRITABLE_FIELDS)
|
|
42
|
+
if patch.area is not None:
|
|
43
|
+
self.repository.update_area(profile_id=profile_id, area=patch.area)
|
|
44
|
+
return self.repository.get_user_profile_entity(profile_id=profile_id)
|
|
45
|
+
|
|
46
|
+
def delete_profile(self, *, profile_id: int, actor_profile_id: int | None) -> bool:
|
|
47
|
+
profile = self.repository.get_by_id(profile_id)
|
|
48
|
+
if profile is None:
|
|
49
|
+
return False
|
|
50
|
+
if profile.deleted_on is not None:
|
|
51
|
+
return True
|
|
52
|
+
self.repository.soft_delete(
|
|
53
|
+
profile_id=profile_id, actor_profile_id=actor_profile_id
|
|
54
|
+
)
|
|
55
|
+
return True
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Signup domain workflow; only entropy, password hashing and time are providers."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
8
|
+
|
|
9
|
+
from platform_core.enums import ProfileArea
|
|
10
|
+
from platform_core.models.contracts import (
|
|
11
|
+
ProfileModel,
|
|
12
|
+
SignupInvitationModel,
|
|
13
|
+
UserModel,
|
|
14
|
+
)
|
|
15
|
+
from platform_core.repositories.profiles import ProfileRepository
|
|
16
|
+
from platform_core.repositories.signup import SignupInvitationRepository
|
|
17
|
+
from platform_core.repositories.users import UserRepository
|
|
18
|
+
from platform_core.services.base import PlatformService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def current_time() -> datetime:
|
|
22
|
+
return datetime.now(UTC)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def new_token() -> str:
|
|
26
|
+
return secrets.token_urlsafe(32)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SignupService[
|
|
30
|
+
I: SignupInvitationModel,
|
|
31
|
+
U: UserModel,
|
|
32
|
+
P: ProfileModel,
|
|
33
|
+
](PlatformService[RepositoryFactory]):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
invitations: SignupInvitationRepository[I],
|
|
37
|
+
users: UserRepository[U],
|
|
38
|
+
profiles: ProfileRepository[P],
|
|
39
|
+
*,
|
|
40
|
+
password_hash: Callable[[str], str],
|
|
41
|
+
clock: Callable[[], datetime] = current_time,
|
|
42
|
+
token: Callable[[], str] = new_token,
|
|
43
|
+
) -> None:
|
|
44
|
+
session = invitations.session
|
|
45
|
+
if any(repository.session is not session for repository in (users, profiles)):
|
|
46
|
+
raise ValueError("Signup repositories must share the same session")
|
|
47
|
+
super().__init__(invitations.maybe_factory)
|
|
48
|
+
self.invitations = invitations
|
|
49
|
+
self.users = users
|
|
50
|
+
self.profiles = profiles
|
|
51
|
+
self.password_hash = password_hash
|
|
52
|
+
self.clock = clock
|
|
53
|
+
self.token = token
|
|
54
|
+
|
|
55
|
+
def reuse_or_create_invite(self, *, email: str, first_name: str, last_name: str) -> I:
|
|
56
|
+
existing = self.invitations.find_active_by_email(email)
|
|
57
|
+
if existing is not None and not self.invitation_expired(
|
|
58
|
+
existing.expiration, now=self.clock()
|
|
59
|
+
):
|
|
60
|
+
return existing
|
|
61
|
+
self.invitations.delete_for_email(email)
|
|
62
|
+
token = self.token()
|
|
63
|
+
expiration = self.clock() + timedelta(days=7)
|
|
64
|
+
created = self.clock()
|
|
65
|
+
self.invitations.create_invite(
|
|
66
|
+
email=email,
|
|
67
|
+
first_name=first_name,
|
|
68
|
+
last_name=last_name,
|
|
69
|
+
token=token,
|
|
70
|
+
expiration=expiration,
|
|
71
|
+
created_on=created,
|
|
72
|
+
)
|
|
73
|
+
self.invitations.session.flush()
|
|
74
|
+
invite = self.invitations.find_active_by_email(email)
|
|
75
|
+
if invite is None:
|
|
76
|
+
raise RuntimeError("signup invite was not persisted")
|
|
77
|
+
return invite
|
|
78
|
+
|
|
79
|
+
def complete_signup(
|
|
80
|
+
self, *, token: str, username: str, password: str, area: Sequence[ProfileArea]
|
|
81
|
+
) -> tuple[int, I, P]:
|
|
82
|
+
invite = self.invitations.find_pending_by_token(token)
|
|
83
|
+
if invite is None:
|
|
84
|
+
raise ValueError("invalid_token")
|
|
85
|
+
if self.invitation_expired(invite.expiration, now=self.clock()):
|
|
86
|
+
raise ValueError("expired_token")
|
|
87
|
+
if self.users.exists_where(username=username):
|
|
88
|
+
raise ValueError("username_taken")
|
|
89
|
+
user = self.users.create(
|
|
90
|
+
username=username,
|
|
91
|
+
email=invite.email,
|
|
92
|
+
first_name=invite.first_name,
|
|
93
|
+
last_name=invite.last_name,
|
|
94
|
+
password=self.password_hash(password),
|
|
95
|
+
is_active=True,
|
|
96
|
+
is_staff=False,
|
|
97
|
+
is_superuser=False,
|
|
98
|
+
date_joined=self.clock(),
|
|
99
|
+
last_login=None,
|
|
100
|
+
)
|
|
101
|
+
profile_id = self.profiles.create_user_profile(
|
|
102
|
+
user_id=user.id,
|
|
103
|
+
department_id=None,
|
|
104
|
+
title="",
|
|
105
|
+
area=[value.value for value in area],
|
|
106
|
+
email=invite.email,
|
|
107
|
+
first_name=invite.first_name,
|
|
108
|
+
last_name=invite.last_name,
|
|
109
|
+
)
|
|
110
|
+
self.invitations.mark_completed(token, completed_at=self.clock())
|
|
111
|
+
self.invitations.session.flush()
|
|
112
|
+
return user.id, invite, self.profiles.get(profile_id)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""User edit rules over an owned domain repository."""
|
|
2
|
+
|
|
3
|
+
from sqlphilosophy.sync.protocols import RepositoryFactory
|
|
4
|
+
from sqlphilosophy.types import RowMapping
|
|
5
|
+
|
|
6
|
+
from platform_core.models.contracts import UserModel
|
|
7
|
+
from platform_core.repositories.users import UserRepository
|
|
8
|
+
from platform_core.services.base import PlatformService
|
|
9
|
+
|
|
10
|
+
WRITABLE_FIELDS = frozenset(
|
|
11
|
+
[
|
|
12
|
+
"email",
|
|
13
|
+
"first_name",
|
|
14
|
+
"is_active",
|
|
15
|
+
"is_staff",
|
|
16
|
+
"last_name",
|
|
17
|
+
"phone_number",
|
|
18
|
+
"username",
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UserService[T: UserModel](PlatformService[RepositoryFactory]):
|
|
24
|
+
def __init__(self, repository: UserRepository[T]) -> None:
|
|
25
|
+
super().__init__(repository.maybe_factory)
|
|
26
|
+
self.repository = repository
|
|
27
|
+
|
|
28
|
+
def update_user(self, obj_id: int, fields: RowMapping) -> T | None:
|
|
29
|
+
updates = {key: value for key, value in fields.items() if key in WRITABLE_FIELDS}
|
|
30
|
+
if not updates:
|
|
31
|
+
return self.repository.get_by_id(obj_id)
|
|
32
|
+
if self.repository.update_partial(obj_id, updates, WRITABLE_FIELDS) == 0:
|
|
33
|
+
return None
|
|
34
|
+
self.repository.flush_pending_writes()
|
|
35
|
+
return self.repository.get_by_id(obj_id)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: signalsafe-platform-core
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Shared platform-core domain services and repositories
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: <4,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: servicephilosophy<0.2,>=0.1.2
|
|
10
|
+
Requires-Dist: sqlphilosophy<0.4,>=0.3.4
|
|
11
|
+
Requires-Dist: sqlalchemy<3,>=2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# platform-core Python
|
|
15
|
+
|
|
16
|
+
See [Python models and services](docs/README.md). Python 0.4.0 is the Company-free release approved on 2026-10-21; TypeScript remains 0.1.0. Existing hosts stay on their prior compatible artifacts until tenant-bound session adoption.
|
|
17
|
+
|
|
18
|
+
Coordinated candidate dependencies: servicePhilosophy >=0.1.2,<0.2 and SQLPhilosophy >=0.3.4,<0.4. Build tooling requires patched setuptools >=84.2.1,<85. These unpublished minima are tested with exact local wheels before approval; fresh registry-only frozen checks remain required after upstream publication.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
docs/README.md
|
|
6
|
+
src/platform_core/__init__.py
|
|
7
|
+
src/platform_core/enums.py
|
|
8
|
+
src/platform_core/py.typed
|
|
9
|
+
src/platform_core/models/__init__.py
|
|
10
|
+
src/platform_core/models/base.py
|
|
11
|
+
src/platform_core/models/contracts.py
|
|
12
|
+
src/platform_core/models/defaults.py
|
|
13
|
+
src/platform_core/repositories/__init__.py
|
|
14
|
+
src/platform_core/repositories/departments.py
|
|
15
|
+
src/platform_core/repositories/profiles.py
|
|
16
|
+
src/platform_core/repositories/signup.py
|
|
17
|
+
src/platform_core/repositories/users.py
|
|
18
|
+
src/platform_core/services/__init__.py
|
|
19
|
+
src/platform_core/services/base.py
|
|
20
|
+
src/platform_core/services/departments.py
|
|
21
|
+
src/platform_core/services/invitations.py
|
|
22
|
+
src/platform_core/services/profiles.py
|
|
23
|
+
src/platform_core/services/signup.py
|
|
24
|
+
src/platform_core/services/users.py
|
|
25
|
+
src/signalsafe_platform_core.egg-info/PKG-INFO
|
|
26
|
+
src/signalsafe_platform_core.egg-info/SOURCES.txt
|
|
27
|
+
src/signalsafe_platform_core.egg-info/dependency_links.txt
|
|
28
|
+
src/signalsafe_platform_core.egg-info/requires.txt
|
|
29
|
+
src/signalsafe_platform_core.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
platform_core
|