sysnet-persons-model 1.4.2__tar.gz → 1.6.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 (33) hide show
  1. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/PKG-INFO +5 -1
  2. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/README.md +3 -0
  3. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/__init__.py +6 -0
  4. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/common.py +43 -3
  5. sysnet_persons_model-1.6.0/persons_model/dates.py +87 -0
  6. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/individual.py +13 -2
  7. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/person_entity.py +33 -3
  8. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/pyproject.toml +2 -1
  9. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/sysnet_persons_model.egg-info/PKG-INFO +5 -1
  10. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/sysnet_persons_model.egg-info/SOURCES.txt +2 -0
  11. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/sysnet_persons_model.egg-info/requires.txt +1 -0
  12. sysnet_persons_model-1.6.0/tests/test_birth_date.py +155 -0
  13. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_person_entity.py +45 -0
  14. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/LICENSE +0 -0
  15. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/config.py +0 -0
  16. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/context.py +0 -0
  17. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/country.py +0 -0
  18. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/legal_form.py +0 -0
  19. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/role.py +0 -0
  20. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/persons_model/tag.py +0 -0
  21. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/requirements.txt +0 -0
  22. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/setup.cfg +0 -0
  23. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/sysnet_persons_model.egg-info/dependency_links.txt +0 -0
  24. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/sysnet_persons_model.egg-info/top_level.txt +0 -0
  25. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_common.py +0 -0
  26. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_config.py +0 -0
  27. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_context.py +0 -0
  28. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_country.py +0 -0
  29. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_individual.py +0 -0
  30. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_legal_form.py +0 -0
  31. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_role.py +0 -0
  32. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_scope.py +0 -0
  33. {sysnet_persons_model-1.4.2 → sysnet_persons_model-1.6.0}/tests/test_tag.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sysnet-persons-model
3
- Version: 1.4.2
3
+ Version: 1.6.0
4
4
  Summary: SYSNET Persons Data Model
5
5
  Author-email: Data Developer <info@sysnet.cz>
6
6
  License-Expression: LicenseRef-SYSNET-FreeForHomeUse
@@ -18,12 +18,15 @@ Requires-Dist: sysnet-pyutils>=1.7.0
18
18
  Requires-Dist: pydantic[email]>=2.11.4
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: pytest>=9.0; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.26; extra == "dev"
21
22
  Requires-Dist: build>=1.0; extra == "dev"
22
23
  Requires-Dist: twine>=5.0; extra == "dev"
23
24
  Dynamic: license-file
24
25
 
25
26
  # sysnet-persons-model
26
27
 
28
+ ![Verze 1.4.2](https://img.shields.io/badge/verze-1.4.2-blue)
29
+
27
30
  Data model pro entity a osoby v ekosystému SYSNET. Postaveno na Pydantic v2.
28
31
 
29
32
  ## Instalace
@@ -39,6 +42,7 @@ pip install sysnet-persons-model
39
42
  - **Opaque extension slot** (`agenda`) pro agendově specifická data.
40
43
 
41
44
  ## Verze
45
+ - **1.4.2**: Oprava validace `ScopeType` — rozsah `GLOBAL` s neprázdným seznamem jurisdikcí nyní vyvolá Pydantic `ValidationError` se zprávou „Globální rozsah nesmí definovat jurisdikce.“ místo jejich tichého ignorování. Sjednocení souboru `VERSION` na `1.4.2` a přidání `pytest-asyncio>=0.26` do vývojových závislostí. Viz [CHANGELOG.md](CHANGELOG.md).
42
46
  - **1.3.0**: Přidání atributu `agenda` (opaque extension slot) do `IndividualBaseType` a `PersonEntityBaseType`. Viz [AGENDA_GUIDE.md](docs/AGENDA_GUIDE.md).
43
47
  - **1.2.8**: Interní verze, přidání atributu `agenda` (špatná cesta k docs).
44
48
  - **1.2.7**: Přidání aliasu `metadata` pro pole `document` (Redmine #9850).
@@ -1,5 +1,7 @@
1
1
  # sysnet-persons-model
2
2
 
3
+ ![Verze 1.4.2](https://img.shields.io/badge/verze-1.4.2-blue)
4
+
3
5
  Data model pro entity a osoby v ekosystému SYSNET. Postaveno na Pydantic v2.
4
6
 
5
7
  ## Instalace
@@ -15,6 +17,7 @@ pip install sysnet-persons-model
15
17
  - **Opaque extension slot** (`agenda`) pro agendově specifická data.
16
18
 
17
19
  ## Verze
20
+ - **1.4.2**: Oprava validace `ScopeType` — rozsah `GLOBAL` s neprázdným seznamem jurisdikcí nyní vyvolá Pydantic `ValidationError` se zprávou „Globální rozsah nesmí definovat jurisdikce.“ místo jejich tichého ignorování. Sjednocení souboru `VERSION` na `1.4.2` a přidání `pytest-asyncio>=0.26` do vývojových závislostí. Viz [CHANGELOG.md](CHANGELOG.md).
18
21
  - **1.3.0**: Přidání atributu `agenda` (opaque extension slot) do `IndividualBaseType` a `PersonEntityBaseType`. Viz [AGENDA_GUIDE.md](docs/AGENDA_GUIDE.md).
19
22
  - **1.2.8**: Interní verze, přidání atributu `agenda` (špatná cesta k docs).
20
23
  - **1.2.7**: Přidání aliasu `metadata` pro pole `document` (Redmine #9850).
@@ -4,6 +4,7 @@ from persons_model.common import (
4
4
  RedundantType,
5
5
  DepartmentType,
6
6
  ContactType,
7
+ ContactEntryType,
7
8
  MembershipEnum,
8
9
  MemberType,
9
10
  IssuingType,
@@ -20,7 +21,9 @@ from persons_model.legal_form import (
20
21
  from persons_model.person_entity import (
21
22
  PersonEntityLinkType,
22
23
  PersonEntityEntryType,
24
+ PersonEntityExtEntryType,
23
25
  PersonEntityListType,
26
+ PersonEntityExtListType,
24
27
  PersonEntityBaseType,
25
28
  PersonEntityType,
26
29
  )
@@ -34,6 +37,7 @@ __all__ = [
34
37
  'RedundantType',
35
38
  'DepartmentType',
36
39
  'ContactType',
40
+ 'ContactEntryType',
37
41
  'MembershipEnum',
38
42
  'MemberType',
39
43
  'IssuingType',
@@ -58,7 +62,9 @@ __all__ = [
58
62
  # person_entity
59
63
  'PersonEntityLinkType',
60
64
  'PersonEntityEntryType',
65
+ 'PersonEntityExtEntryType',
61
66
  'PersonEntityListType',
67
+ 'PersonEntityExtListType',
62
68
  'PersonEntityBaseType',
63
69
  'PersonEntityType',
64
70
  # role
@@ -3,9 +3,12 @@ from __future__ import annotations
3
3
  from datetime import datetime
4
4
  from typing import List, Optional
5
5
 
6
- from pydantic import BaseModel, Field, StrictBool, field_validator
6
+ from datetime import date
7
+
8
+ from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
7
9
  from sysnet_pyutils.models.general import LocationType, PhoneNumberType, MailAddressType, BaseEnum
8
10
 
11
+ from persons_model.dates import PureDate, sync_birth_fields
9
12
  from persons_model.tag import TagType
10
13
 
11
14
 
@@ -55,7 +58,39 @@ class ContactType(BaseModel):
55
58
  degree_before: Optional[List[Optional[str]]] = Field(default=None, description="Tituly před jménem")
56
59
  degree_after: Optional[List[Optional[str]]] = Field(default=None, description="Tituly za jménem")
57
60
  full_name: Optional[str] = Field(default=None, description="Celé jméno pro zobrazení a tisk")
58
- birthdate: Optional[datetime] = Field(default=None, description="Datum narození")
61
+ birthdate: Optional[datetime] = Field(
62
+ default=None,
63
+ description="Datum narození jako okamžik (zastaralé, ponecháno kvůli zpětné kompatibilitě). "
64
+ "Použijte birth_date.", json_schema_extra={'deprecated': True})
65
+ birth_date: Optional[PureDate] = Field(
66
+ default=None, description="Datum narození jako čisté datum (YYYY-MM-DD, bez času a zóny)")
67
+ phone: Optional[List[Optional[PhoneNumberType]]] = Field(default=None, description="Telefonní číslo")
68
+ phone_default: Optional[int] = Field(default=0, description="Index hlavního telefonního čísla", alias="phoneDefault")
69
+ email: Optional[List[Optional[MailAddressType]]] = Field(default=None, description="Elektronická pošta")
70
+ email_default: Optional[int] = Field(default=0, description="Index hlavní adresy elektronické pošty")
71
+ web: Optional[str] = Field(default=None, description="Webová stránka subjektu")
72
+ isds: Optional[str] = Field(default=None, description="Identifikátor datové schránky")
73
+ reference: Optional[str] = Field(default=None, description="identifikátor uuid")
74
+
75
+ @model_validator(mode='before')
76
+ @classmethod
77
+ def _sync_birth_fields(cls, data):
78
+ return sync_birth_fields(data)
79
+
80
+
81
+ class ContactEntryType(BaseModel):
82
+ """
83
+ Kontaktní údaje ve výpisu (``GET /person?extended=true``).
84
+
85
+ Stejná pole a stejné aliasy jako ``ContactType``, **bez** ``birthdate`` — datum narození zástupce
86
+ nepatří do hromadného výpisu (minimalizace osobních údajů); je dostupné jen v detailu subjektu.
87
+ """
88
+ identifier: Optional[str] = Field(default=None, description="identifikátor uuid")
89
+ first_name: Optional[str] = Field(default=None, description="Křestní jméno")
90
+ last_name: Optional[str] = Field(default=None, description="Příjmení")
91
+ degree_before: Optional[List[Optional[str]]] = Field(default=None, description="Tituly před jménem")
92
+ degree_after: Optional[List[Optional[str]]] = Field(default=None, description="Tituly za jménem")
93
+ full_name: Optional[str] = Field(default=None, description="Celé jméno pro zobrazení a tisk")
59
94
  phone: Optional[List[Optional[PhoneNumberType]]] = Field(default=None, description="Telefonní číslo")
60
95
  phone_default: Optional[int] = Field(default=0, description="Index hlavního telefonního čísla", alias="phoneDefault")
61
96
  email: Optional[List[Optional[MailAddressType]]] = Field(default=None, description="Elektronická pošta")
@@ -130,8 +165,13 @@ class ScopeType(BaseModel):
130
165
  def validate_jurisdictions(cls, jurisdictions, info):
131
166
  level = info.data.get("level")
132
167
 
168
+ if level == ScopeLevelEnum.GLOBAL:
169
+ if jurisdictions:
170
+ raise ValueError("Globální rozsah nesmí definovat jurisdikce.")
171
+ return None
172
+
133
173
  # Úrovně s implicitním celým rozsahem — jurisdikce se nevyžadují ani nezakazují
134
- BROAD_LEVELS = {ScopeLevelEnum.GLOBAL, ScopeLevelEnum.NATIONAL, ScopeLevelEnum.CONTINENTAL}
174
+ BROAD_LEVELS = {ScopeLevelEnum.NATIONAL, ScopeLevelEnum.CONTINENTAL}
135
175
 
136
176
  if level in BROAD_LEVELS:
137
177
  # Prázdné jurisdikce znamenají „celý rozsah dané úrovně" — jsou v pořádku
@@ -0,0 +1,87 @@
1
+ """
2
+ Převod hodnot data narození na kalendářní datum (Redmine #10334).
3
+
4
+ Registr osob dosud ukládal datum narození jako ``datetime`` (naivní, většinou půlnoc UTC). Klienti ale
5
+ posílali různé tvary (``2000-01-02``, ``…T12:00:00``, ``…T22:00:00Z``), takže se den při převodech mezi
6
+ Prahou a UTC posouval. Kalendářní datum zóny nemá; jediné pravidlo je zde:
7
+
8
+ - hodnota **se zónou** (``Z``, ``+00:00``, …) → den v ``Europe/Prague``,
9
+ - hodnota **bez zóny** (``date``, naivní ``datetime``, řetězec bez zóny) → den tak, jak je.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from datetime import date, datetime
14
+ from typing import Annotated, Any, Optional
15
+ from zoneinfo import ZoneInfo
16
+
17
+ from pydantic import BeforeValidator
18
+
19
+ PRAGUE = ZoneInfo("Europe/Prague")
20
+
21
+
22
+ def to_calendar_date(value: Any) -> Optional[date]:
23
+ """Vrátí kalendářní datum z ``date``, ``datetime`` nebo ISO řetězce; ``None`` zůstane ``None``.
24
+
25
+ :raises ValueError: pokud hodnotu nelze přečíst jako datum.
26
+ """
27
+ if value is None or value == '':
28
+ return None
29
+ if isinstance(value, datetime):
30
+ if value.tzinfo is None:
31
+ return value.date()
32
+ try:
33
+ return value.astimezone(PRAGUE).date()
34
+ except OverflowError:
35
+ # okraj rozsahu (0001-01-01 / 9999-12-31): posun o zónu by přetekl, bereme den tak, jak je
36
+ return value.date()
37
+ if isinstance(value, date):
38
+ return value
39
+ if isinstance(value, str):
40
+ text = value.strip()
41
+ if text.endswith(('Z', 'z')):
42
+ text = text[:-1] + '+00:00'
43
+ if 'T' in text or ' ' in text:
44
+ try:
45
+ parsed = datetime.fromisoformat(text)
46
+ except ValueError as exc:
47
+ raise ValueError(f"Neplatné datum: {value!r}") from exc
48
+ return to_calendar_date(parsed)
49
+ try:
50
+ return date.fromisoformat(text)
51
+ except ValueError as exc:
52
+ raise ValueError(f"Neplatné datum: {value!r}") from exc
53
+ raise ValueError(f"Neplatné datum: {value!r}")
54
+
55
+
56
+ def to_midnight(value: Optional[date]) -> Optional[datetime]:
57
+ """Půlnoc daného dne jako naivní ``datetime`` (kompatibilní tvar pro starší pole ``birthdate``)."""
58
+ if value is None:
59
+ return None
60
+ return datetime(value.year, value.month, value.day)
61
+
62
+
63
+ PureDate = Annotated[date, BeforeValidator(to_calendar_date)]
64
+ """Kalendářní datum: ``date`` v modelu, ``YYYY-MM-DD`` v JSON, ``format: date`` v OpenAPI."""
65
+
66
+
67
+ def sync_birth_fields(data: Any) -> Any:
68
+ """Sjednotí ``birth_date`` (nové, ``date``) a ``birthdate`` (staré, ``datetime``) při validaci vstupu.
69
+
70
+ - zadáno ``birth_date`` → platí a ``birthdate`` se dopočítá jako půlnoc téhož dne (bez zóny),
71
+ - zadáno jen ``birthdate`` → ``birth_date`` se dopočítá (zóna → den v Praze), ``birthdate`` zůstane beze změny,
72
+ - nezadáno nic → data se nemění (kvůli ``exclude_unset``).
73
+ Vstup se nemění na místě.
74
+ """
75
+ if not isinstance(data, dict):
76
+ return data
77
+ new_value = data.get('birth_date')
78
+ old_value = data.get('birthdate')
79
+ if new_value not in (None, ''):
80
+ day = to_calendar_date(new_value)
81
+ data = dict(data)
82
+ data['birth_date'] = day
83
+ data['birthdate'] = to_midnight(day)
84
+ elif old_value not in (None, ''):
85
+ data = dict(data)
86
+ data['birth_date'] = to_calendar_date(old_value)
87
+ return data
@@ -3,10 +3,11 @@ from __future__ import annotations
3
3
  from datetime import datetime
4
4
  from typing import Any, List, Optional
5
5
 
6
- from pydantic import BaseModel, Field, SecretStr, StrictBool, AliasChoices
6
+ from pydantic import BaseModel, Field, SecretStr, StrictBool, AliasChoices, model_validator
7
7
  from sysnet_pyutils.models.general import LocationType, ListTypeBase, MetadataEssentialType
8
8
 
9
9
  from persons_model.common import ContactType, RegistryType
10
+ from persons_model.dates import PureDate, sync_birth_fields
10
11
  from persons_model.tag import TagType
11
12
 
12
13
 
@@ -23,11 +24,21 @@ class IndividualBaseType(BaseModel):
23
24
  address: Optional[List[Optional[LocationType]]] = Field(default=None, description='Seznam adres')
24
25
  address_default: Optional[int] = Field(default=0, description="Primární adresní údaje")
25
26
  address_printable: Optional[str] = Field(default=None, description="Adresa pro tisk")
26
- birthdate: Optional[datetime] = Field(default=None, description="Datum narození")
27
+ birthdate: Optional[datetime] = Field(
28
+ default=None,
29
+ description="Datum narození jako okamžik (zastaralé, ponecháno kvůli zpětné kompatibilitě). "
30
+ "Použijte birth_date.", json_schema_extra={'deprecated': True})
31
+ birth_date: Optional[PureDate] = Field(
32
+ default=None, description="Datum narození jako čisté datum (YYYY-MM-DD, bez času a zóny)")
27
33
  gdpr: Optional[StrictBool] = Field(default=True, description="Udělen souhlas se zpracováním dat GDPR")
28
34
  note: Optional[str] = Field(default=None, description='Libovolná poznámka')
29
35
  agenda: Optional[dict[str, dict[str, Any]]] = Field(default=None, description='Agendově specifická data. Klíč = kód agendy (hnvo, ippc, cites). Hodnota = libovolný objekt parsovaný konzumentem.')
30
36
 
37
+ @model_validator(mode='before')
38
+ @classmethod
39
+ def _sync_birth_fields(cls, data):
40
+ return sync_birth_fields(data)
41
+
31
42
 
32
43
  class IndividualEntryType(BaseModel):
33
44
  """
@@ -3,11 +3,12 @@ from __future__ import annotations
3
3
  from datetime import datetime
4
4
  from typing import TYPE_CHECKING, Any, List, Optional, Union
5
5
 
6
- from pydantic import BaseModel, Field, StrictBool, AliasChoices
6
+ from pydantic import BaseModel, Field, StrictBool, AliasChoices, model_validator
7
7
  from sysnet_pyutils.models.general import LocationType, PersonTypeEnum, ListTypeBase, MetadataEssentialType
8
8
 
9
- from persons_model.common import RedundantType, DepartmentType, ContactType, IssuingType, RegistryType, MemberType, ScopeType
9
+ from persons_model.common import RedundantType, DepartmentType, ContactType, ContactEntryType, IssuingType, RegistryType, MemberType, ScopeType
10
10
  from persons_model.legal_form import LegalFormEnum, LegalFormType, make_legal_form, person_type_from_legal_form
11
+ from persons_model.dates import PureDate, sync_birth_fields
11
12
  from persons_model.tag import TagType
12
13
 
13
14
 
@@ -43,6 +44,17 @@ class PersonEntityEntryType(BaseModel):
43
44
  registry: Optional[RegistryType] = Field(default=None, description="Vazba na referenční registr (CRŽP uuid)")
44
45
 
45
46
 
47
+ class PersonEntityExtEntryType(PersonEntityEntryType):
48
+ """
49
+ Položka rozšířeného výpisu subjektů (``GET /person?extended=true``, Redmine #10281).
50
+
51
+ Ke standardní položce přidává kontakty a zástupce, aby je klient (katalog úřadů) nemusel načítat
52
+ z detailu každého subjektu zvlášť. Datum narození se ve výpisu nevrací.
53
+ """
54
+ contacts: Optional[List[Optional[ContactEntryType]]] = Field(default=None, description="Kontakty subjektu (e-mail, telefon, datová schránka, web)")
55
+ representative: Optional[ContactEntryType] = Field(default=None, description="Kontaktní osoba / zástupce subjektu")
56
+
57
+
46
58
  class PersonEntityListType(ListTypeBase):
47
59
  """
48
60
  Seznam vrácených subjektů
@@ -51,13 +63,26 @@ class PersonEntityListType(ListTypeBase):
51
63
  entries: Optional[List[Optional[PersonEntityEntryType]]] = Field(default=None, description='Položky seznamu')
52
64
 
53
65
 
66
+ class PersonEntityExtListType(ListTypeBase):
67
+ """
68
+ Seznam subjektů s kontakty (``GET /person?extended=true``)
69
+ """
70
+ hits: Optional[int] = Field(default=None, description="celkový počet vybraných položek")
71
+ entries: Optional[List[Optional[PersonEntityExtEntryType]]] = Field(default=None, description='Položky seznamu')
72
+
73
+
54
74
  class PersonEntityBaseType(BaseModel):
55
75
  """
56
76
  Osoba, subjekt. Fyzická nebo právnická osoba.
57
77
  """ # noqa: E501
58
78
  code: Optional[str] = Field(default=None, description="Kód subjektu")
59
79
  ico: Optional[str] = Field(default=None, description="Jiný identifikátor (IČO)")
60
- birthdate: Optional[datetime] = Field(default=None, description="Datum narození")
80
+ birthdate: Optional[datetime] = Field(
81
+ default=None,
82
+ description="Datum narození jako okamžik (zastaralé, ponecháno kvůli zpětné kompatibilitě). "
83
+ "Použijte birth_date.", json_schema_extra={'deprecated': True})
84
+ birth_date: Optional[PureDate] = Field(
85
+ default=None, description="Datum narození jako čisté datum (YYYY-MM-DD, bez času a zóny)")
61
86
  person_type: Optional[PersonTypeEnum] = Field(default=None, description='Typ osoby (zdroj CRŽP)')
62
87
  legal_form: Optional[LegalFormType] = Field(default=None, description='Právní forma osoby (číselník EGON RPP); pole code = LegalFormEnum, pole name = český název')
63
88
  name: Optional[str] = Field(default=None, description="Název nebo jméno osoby")
@@ -74,6 +99,11 @@ class PersonEntityBaseType(BaseModel):
74
99
  scope: Optional[ScopeType] = Field(default=None, description="Rozsah působnosti subjektu")
75
100
  agenda: Optional[dict[str, dict[str, Any]]] = Field(default=None, description="Agendově specifická data. Klíč = kód agendy (hnvo, ippc, cites). Hodnota = libovolný objekt parsovaný konzumentem.")
76
101
 
102
+ @model_validator(mode='before')
103
+ @classmethod
104
+ def _sync_birth_fields(cls, data):
105
+ return sync_birth_fields(data)
106
+
77
107
  def set_legal_form(self, code: Union[LegalFormEnum, str]) -> "PersonEntityBaseType":
78
108
  """Nastaví atribut legal_form podle kódu právní formy.
79
109
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sysnet-persons-model"
7
- version = "1.4.2"
7
+ version = "1.6.0"
8
8
  authors = [
9
9
  { name="Data Developer", email="info@sysnet.cz" },
10
10
  ]
@@ -28,6 +28,7 @@ dependencies = {file = ["requirements.txt"]}
28
28
  [project.optional-dependencies]
29
29
  dev = [
30
30
  "pytest>=9.0",
31
+ "pytest-asyncio>=0.26",
31
32
  "build>=1.0",
32
33
  "twine>=5.0",
33
34
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sysnet-persons-model
3
- Version: 1.4.2
3
+ Version: 1.6.0
4
4
  Summary: SYSNET Persons Data Model
5
5
  Author-email: Data Developer <info@sysnet.cz>
6
6
  License-Expression: LicenseRef-SYSNET-FreeForHomeUse
@@ -18,12 +18,15 @@ Requires-Dist: sysnet-pyutils>=1.7.0
18
18
  Requires-Dist: pydantic[email]>=2.11.4
19
19
  Provides-Extra: dev
20
20
  Requires-Dist: pytest>=9.0; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.26; extra == "dev"
21
22
  Requires-Dist: build>=1.0; extra == "dev"
22
23
  Requires-Dist: twine>=5.0; extra == "dev"
23
24
  Dynamic: license-file
24
25
 
25
26
  # sysnet-persons-model
26
27
 
28
+ ![Verze 1.4.2](https://img.shields.io/badge/verze-1.4.2-blue)
29
+
27
30
  Data model pro entity a osoby v ekosystému SYSNET. Postaveno na Pydantic v2.
28
31
 
29
32
  ## Instalace
@@ -39,6 +42,7 @@ pip install sysnet-persons-model
39
42
  - **Opaque extension slot** (`agenda`) pro agendově specifická data.
40
43
 
41
44
  ## Verze
45
+ - **1.4.2**: Oprava validace `ScopeType` — rozsah `GLOBAL` s neprázdným seznamem jurisdikcí nyní vyvolá Pydantic `ValidationError` se zprávou „Globální rozsah nesmí definovat jurisdikce.“ místo jejich tichého ignorování. Sjednocení souboru `VERSION` na `1.4.2` a přidání `pytest-asyncio>=0.26` do vývojových závislostí. Viz [CHANGELOG.md](CHANGELOG.md).
42
46
  - **1.3.0**: Přidání atributu `agenda` (opaque extension slot) do `IndividualBaseType` a `PersonEntityBaseType`. Viz [AGENDA_GUIDE.md](docs/AGENDA_GUIDE.md).
43
47
  - **1.2.8**: Interní verze, přidání atributu `agenda` (špatná cesta k docs).
44
48
  - **1.2.7**: Přidání aliasu `metadata` pro pole `document` (Redmine #9850).
@@ -7,6 +7,7 @@ persons_model/common.py
7
7
  persons_model/config.py
8
8
  persons_model/context.py
9
9
  persons_model/country.py
10
+ persons_model/dates.py
10
11
  persons_model/individual.py
11
12
  persons_model/legal_form.py
12
13
  persons_model/person_entity.py
@@ -17,6 +18,7 @@ sysnet_persons_model.egg-info/SOURCES.txt
17
18
  sysnet_persons_model.egg-info/dependency_links.txt
18
19
  sysnet_persons_model.egg-info/requires.txt
19
20
  sysnet_persons_model.egg-info/top_level.txt
21
+ tests/test_birth_date.py
20
22
  tests/test_common.py
21
23
  tests/test_config.py
22
24
  tests/test_context.py
@@ -3,5 +3,6 @@ pydantic[email]>=2.11.4
3
3
 
4
4
  [dev]
5
5
  pytest>=9.0
6
+ pytest-asyncio>=0.26
6
7
  build>=1.0
7
8
  twine>=5.0
@@ -0,0 +1,155 @@
1
+ """
2
+ Testy pole ``birth_date`` (čisté datum) vedle ``birthdate`` (datetime) — Redmine #10334, cesta C.
3
+
4
+ Pravidla:
5
+ - ``birth_date`` je kalendářní datum (``datetime.date``), v JSON ``YYYY-MM-DD``, v OpenAPI ``format: date``.
6
+ - Hodnota se zónou (``Z``, ``+00:00``) se převádí na den v ``Europe/Prague``; hodnota bez zóny se bere tak, jak je.
7
+ - ``birthdate`` zůstává kvůli zpětné kompatibilitě; při vstupu jen s ním se ``birth_date`` dopočítá,
8
+ při vstupu jen s ``birth_date`` se ``birthdate`` dopočítá jako půlnoc téhož dne (bez zóny, jako dosud).
9
+ - Pokud přijdou obě pole, platí ``birth_date`` (nové pole má přednost).
10
+ """
11
+ from datetime import date, datetime
12
+
13
+ import pytest
14
+
15
+ from persons_model.common import ContactType
16
+ from persons_model.dates import to_calendar_date
17
+ from persons_model.individual import IndividualBaseType, IndividualType
18
+ from persons_model.person_entity import PersonEntityBaseType, PersonEntityType
19
+
20
+ TYPES = [ContactType, IndividualBaseType, IndividualType, PersonEntityBaseType, PersonEntityType]
21
+
22
+
23
+ class TestToCalendarDate:
24
+ @pytest.mark.parametrize("raw, expected", [
25
+ ("1972-08-26", date(1972, 8, 26)),
26
+ ("1972-08-26T00:00:00", date(1972, 8, 26)),
27
+ ("1972-08-26T12:00:00", date(1972, 8, 26)),
28
+ ("1972-08-26 12:00:00", date(1972, 8, 26)),
29
+ # hodnoty se zónou: den v pražském čase. V roce 1972 platilo SEČ (UTC+1) i v létě,
30
+ # letní čas byl zaveden až 1979 — zoneinfo to ví.
31
+ ("1972-08-26T00:00:00Z", date(1972, 8, 26)), # 01:00 SEČ
32
+ ("1972-08-25T22:00:00Z", date(1972, 8, 25)), # 23:00 SEČ 25. 8.
33
+ ("1972-08-25T23:00:00Z", date(1972, 8, 26)), # 00:00 SEČ 26. 8.
34
+ ("1972-12-31T23:00:00Z", date(1973, 1, 1)), # 00:00 SEČ
35
+ ("1972-12-31T22:00:00Z", date(1972, 12, 31)),
36
+ ("1972-08-26T00:00:00+01:00", date(1972, 8, 26)), # zóna odpovídá Praze
37
+ (datetime(1972, 8, 26, 0, 0), date(1972, 8, 26)),
38
+ (datetime(1972, 8, 26, 12, 0), date(1972, 8, 26)),
39
+ (date(1972, 8, 26), date(1972, 8, 26)),
40
+ (None, None),
41
+ ])
42
+ def test_prevod(self, raw, expected):
43
+ assert to_calendar_date(raw) == expected
44
+
45
+ def test_letni_cas_po_roce_1979(self):
46
+ # 1990-07-14T22:00Z = 00:00 SELČ 15. 7. (u dat z portálu typický případ „22:00Z“)
47
+ assert to_calendar_date("1990-07-14T22:00:00Z") == date(1990, 7, 15)
48
+ # zimní 23:00Z = 00:00 SEČ
49
+ assert to_calendar_date("1990-01-14T23:00:00Z") == date(1990, 1, 15)
50
+ # hranice letního času po roce 1979: 21:00Z je ještě 23:00 SELČ téhož dne
51
+ assert to_calendar_date("1990-07-14T21:00:00Z") == date(1990, 7, 14)
52
+
53
+ def test_krajni_roky_nepadaji(self):
54
+ assert to_calendar_date("0172-08-26T00:00:00Z").year == 172
55
+ # okraje rozsahu datetime: posun o pražskou zónu by přetekl, vrací se den z hodnoty
56
+ assert to_calendar_date("9999-12-31T23:00:00Z") == date(9999, 12, 31)
57
+ assert to_calendar_date("0001-01-01T00:00:00Z") == date(1, 1, 1)
58
+ # v datech CITES jsou i roky jako 6666 — běžný převod
59
+ assert to_calendar_date("6666-08-26T22:00:00Z") == date(6666, 8, 27)
60
+
61
+ def test_neplatna_hodnota(self):
62
+ with pytest.raises(ValueError):
63
+ to_calendar_date("není datum")
64
+
65
+
66
+ @pytest.mark.parametrize("cls", TYPES)
67
+ class TestBirthDatePole:
68
+ def test_vychozi_hodnoty(self, cls):
69
+ o = cls()
70
+ assert o.birth_date is None
71
+ assert o.birthdate is None
72
+
73
+ def test_jen_birth_date_dopocita_birthdate(self, cls):
74
+ o = cls(birth_date="1972-08-26")
75
+ assert o.birth_date == date(1972, 8, 26)
76
+ assert o.birthdate == datetime(1972, 8, 26, 0, 0)
77
+ assert o.birthdate.tzinfo is None
78
+
79
+ def test_jen_birthdate_dopocita_birth_date(self, cls):
80
+ o = cls(birthdate="1972-08-26T12:00:00")
81
+ assert o.birth_date == date(1972, 8, 26)
82
+
83
+ def test_birthdate_se_zonou_je_den_v_praze(self, cls):
84
+ o = cls(birthdate="1990-07-14T22:00:00Z")
85
+ assert o.birth_date == date(1990, 7, 15)
86
+
87
+ def test_obe_pole_birth_date_ma_prednost(self, cls):
88
+ o = cls(birth_date="1972-08-26", birthdate="1972-08-25T22:00:00Z")
89
+ assert o.birth_date == date(1972, 8, 26)
90
+ assert o.birthdate == datetime(1972, 8, 26, 0, 0)
91
+
92
+ def test_birth_date_prijima_i_datetime_tvary(self, cls):
93
+ assert cls(birth_date="1972-08-26T12:00:00").birth_date == date(1972, 8, 26)
94
+ assert cls(birth_date="1990-07-14T22:00:00Z").birth_date == date(1990, 7, 15)
95
+
96
+ def test_json_je_cisty_datum(self, cls):
97
+ o = cls(birth_date="1972-08-26")
98
+ import json
99
+ dumped = json.loads(o.model_dump_json())
100
+ assert dumped["birth_date"] == "1972-08-26"
101
+ assert dumped["birthdate"] == "1972-08-26T00:00:00"
102
+
103
+ def test_openapi_schema_format_date(self, cls):
104
+ prop = cls.model_json_schema()["properties"]["birth_date"]
105
+ formats = [p.get("format") for p in prop.get("anyOf", [prop])]
106
+ assert "date" in formats
107
+ assert "date-time" not in formats
108
+
109
+ def test_exclude_unset_nezanese_nepouzite_pole(self, cls):
110
+ # PUT s částečným tělem: nepoužité pole se nesmí objevit jako None v exclude_unset dumpu
111
+ o = cls(name="x") if "name" in cls.model_fields else cls()
112
+ assert "birth_date" not in o.model_dump(exclude_unset=True)
113
+
114
+ def test_neplatne_datum_je_chyba(self, cls):
115
+ with pytest.raises(ValueError):
116
+ cls(birth_date="32.13.1972")
117
+
118
+ def test_roundtrip_dump_validate(self, cls):
119
+ o = cls(birth_date="1972-08-26")
120
+ again = cls.model_validate(o.model_dump())
121
+ assert again.birth_date == date(1972, 8, 26)
122
+ again2 = cls.model_validate_json(o.model_dump_json())
123
+ assert again2.birth_date == date(1972, 8, 26)
124
+
125
+
126
+ def test_vnorene_kontakty_maji_birth_date():
127
+ p = PersonEntityType(
128
+ representative={"birthdate": "1990-07-14T22:00:00Z"},
129
+ contacts=[{"birth_date": "1980-01-02"}],
130
+ )
131
+ assert p.representative.birth_date == date(1990, 7, 15)
132
+ assert p.contacts[0].birthdate == datetime(1980, 1, 2, 0, 0)
133
+
134
+
135
+ def test_stare_ulozene_hodnoty_z_mongo_se_nactou():
136
+ """Mongo vrací naivní datetime (UTC půlnoc) — načtení nesmí selhat a den zůstane."""
137
+ o = IndividualType(birthdate=datetime(1972, 8, 26, 0, 0))
138
+ assert o.birth_date == date(1972, 8, 26)
139
+
140
+
141
+ @pytest.mark.parametrize("cls", TYPES)
142
+ def test_cteni_birthdate_nevyvolava_varovani(cls):
143
+ """Staré pole je v OpenAPI označeno jako deprecated, ale čtení atributu nesmí hlásit DeprecationWarning."""
144
+ import warnings
145
+ o = cls(birthdate="1972-08-26")
146
+ with warnings.catch_warnings():
147
+ warnings.simplefilter("error")
148
+ assert o.birthdate == datetime(1972, 8, 26, 0, 0)
149
+
150
+
151
+ @pytest.mark.parametrize("cls", TYPES)
152
+ def test_openapi_birthdate_je_deprecated(cls):
153
+ schema = cls.model_json_schema()["properties"]
154
+ assert schema["birthdate"].get("deprecated") is True
155
+ assert not schema["birth_date"].get("deprecated")
@@ -6,6 +6,8 @@ from sysnet_pyutils.models.general import LocationType, PersonTypeEnum
6
6
  from persons_model.person_entity import (
7
7
  PersonEntityLinkType,
8
8
  PersonEntityEntryType,
9
+ PersonEntityExtEntryType,
10
+ PersonEntityExtListType,
9
11
  PersonEntityListType,
10
12
  PersonEntityBaseType,
11
13
  PersonEntityType
@@ -40,6 +42,49 @@ class TestPersonEntityEntryType:
40
42
  assert p.is_admin == True
41
43
 
42
44
 
45
+ class TestPersonEntityExtEntryType:
46
+ """#10281 — rozšířený výpis: kontakty + zástupce, bez data narození."""
47
+
48
+ def test_je_rozsirenim_standardni_polozky(self):
49
+ assert set(PersonEntityEntryType.model_fields) < set(PersonEntityExtEntryType.model_fields)
50
+ assert set(PersonEntityExtEntryType.model_fields) - set(PersonEntityEntryType.model_fields) == {"contacts", "representative"}
51
+
52
+ def test_prazdny(self):
53
+ p = PersonEntityExtEntryType()
54
+ assert p.contacts is None and p.representative is None
55
+
56
+ def test_kontakty_a_zastupce(self):
57
+ raw = {"identifier": "u1", "name": "Úřad", "contacts": [{"email": [{"name": None, "email": "a@b.cz"}], "isds": "bbb666",
58
+ "web": "http://x.cz", "phone": [{"number": "606111222"}], "phoneDefault": 0}],
59
+ "representative": {"full_name": "Přemysl Ďáblík", "birthdate": "1970-01-01T00:00:00", "email": [{"email": "d@h.cz"}]}}
60
+ p = PersonEntityExtEntryType(**raw)
61
+ assert p.contacts[0].isds == "bbb666" and p.contacts[0].email[0].email == "a@b.cz"
62
+ assert p.contacts[0].phone[0].number == "606111222"
63
+ assert p.representative.full_name == "Přemysl Ďáblík"
64
+
65
+ def test_datum_narozeni_se_do_vypisu_nedostane(self):
66
+ p = PersonEntityExtEntryType(identifier="u1", representative={"full_name": "X", "birthdate": "1970-01-01T00:00:00"})
67
+ assert not hasattr(p.representative, "birthdate")
68
+ assert "birthdate" not in p.model_dump()["representative"]
69
+ assert "birthdate" not in p.model_dump(by_alias=True)["representative"]
70
+
71
+ def test_alias_phoneDefault_ve_vystupu(self):
72
+ p = PersonEntityExtEntryType(identifier="u1", contacts=[{"phoneDefault": 1}])
73
+ assert p.model_dump(by_alias=True)["contacts"][0]["phoneDefault"] == 1
74
+
75
+ def test_plny_model_se_do_vypisu_prevede(self):
76
+ """DbPerson.model_dump() obsahuje contacts s birthdate a další pole — Pydantic je tiše zahodí."""
77
+ full = PersonEntityType(identifier="u1", name="Úřad", contacts=[{"isds": "x", "birthdate": "1970-01-01T00:00:00"}],
78
+ representative={"full_name": "Z", "birthdate": "1970-01-01T00:00:00"}, note="interní")
79
+ e = PersonEntityExtEntryType(**full.model_dump())
80
+ assert e.contacts[0].isds == "x" and e.representative.full_name == "Z"
81
+ assert not hasattr(e, "note")
82
+
83
+ def test_list(self):
84
+ l = PersonEntityExtListType(hits=1, entries=[PersonEntityExtEntryType(identifier="u1")])
85
+ assert l.hits == 1 and l.entries[0].identifier == "u1"
86
+
87
+
43
88
  class TestPersonEntityType:
44
89
  def test_prazdny(self):
45
90
  p = PersonEntityType()