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.
Files changed (31) hide show
  1. signalsafe_platform_core-0.4.0/LICENSE +21 -0
  2. signalsafe_platform_core-0.4.0/MANIFEST.in +2 -0
  3. signalsafe_platform_core-0.4.0/PKG-INFO +18 -0
  4. signalsafe_platform_core-0.4.0/README.md +5 -0
  5. signalsafe_platform_core-0.4.0/docs/README.md +41 -0
  6. signalsafe_platform_core-0.4.0/pyproject.toml +28 -0
  7. signalsafe_platform_core-0.4.0/setup.cfg +4 -0
  8. signalsafe_platform_core-0.4.0/src/platform_core/__init__.py +1 -0
  9. signalsafe_platform_core-0.4.0/src/platform_core/enums.py +32 -0
  10. signalsafe_platform_core-0.4.0/src/platform_core/models/__init__.py +1 -0
  11. signalsafe_platform_core-0.4.0/src/platform_core/models/base.py +18 -0
  12. signalsafe_platform_core-0.4.0/src/platform_core/models/contracts.py +123 -0
  13. signalsafe_platform_core-0.4.0/src/platform_core/models/defaults.py +56 -0
  14. signalsafe_platform_core-0.4.0/src/platform_core/py.typed +0 -0
  15. signalsafe_platform_core-0.4.0/src/platform_core/repositories/__init__.py +1 -0
  16. signalsafe_platform_core-0.4.0/src/platform_core/repositories/departments.py +55 -0
  17. signalsafe_platform_core-0.4.0/src/platform_core/repositories/profiles.py +295 -0
  18. signalsafe_platform_core-0.4.0/src/platform_core/repositories/signup.py +52 -0
  19. signalsafe_platform_core-0.4.0/src/platform_core/repositories/users.py +89 -0
  20. signalsafe_platform_core-0.4.0/src/platform_core/services/__init__.py +1 -0
  21. signalsafe_platform_core-0.4.0/src/platform_core/services/base.py +17 -0
  22. signalsafe_platform_core-0.4.0/src/platform_core/services/departments.py +63 -0
  23. signalsafe_platform_core-0.4.0/src/platform_core/services/invitations.py +59 -0
  24. signalsafe_platform_core-0.4.0/src/platform_core/services/profiles.py +55 -0
  25. signalsafe_platform_core-0.4.0/src/platform_core/services/signup.py +112 -0
  26. signalsafe_platform_core-0.4.0/src/platform_core/services/users.py +35 -0
  27. signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/PKG-INFO +18 -0
  28. signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/SOURCES.txt +29 -0
  29. signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/dependency_links.txt +1 -0
  30. signalsafe_platform_core-0.4.0/src/signalsafe_platform_core.egg-info/requires.txt +3 -0
  31. 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,2 @@
1
+ recursive-include docs *.md
2
+ prune tests
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )
@@ -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,3 @@
1
+ servicephilosophy<0.2,>=0.1.2
2
+ sqlphilosophy<0.4,>=0.3.4
3
+ sqlalchemy<3,>=2