geneweb-py 0.1.0__py3-none-any.whl
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.
- geneweb_py/__init__.py +23 -0
- geneweb_py/analytics/__init__.py +5 -0
- geneweb_py/analytics/network.py +261 -0
- geneweb_py/api/__init__.py +11 -0
- geneweb_py/api/dependencies.py +75 -0
- geneweb_py/api/family_payload.py +64 -0
- geneweb_py/api/limits.py +87 -0
- geneweb_py/api/main.py +222 -0
- geneweb_py/api/middleware/__init__.py +3 -0
- geneweb_py/api/middleware/error_handler.py +153 -0
- geneweb_py/api/middleware/logging.py +100 -0
- geneweb_py/api/models/__init__.py +3 -0
- geneweb_py/api/models/event.py +192 -0
- geneweb_py/api/models/family.py +178 -0
- geneweb_py/api/models/organization.py +154 -0
- geneweb_py/api/models/person.py +311 -0
- geneweb_py/api/models/responses.py +122 -0
- geneweb_py/api/person_payload.py +67 -0
- geneweb_py/api/rate_limit.py +22 -0
- geneweb_py/api/router_helpers.py +32 -0
- geneweb_py/api/routers/__init__.py +8 -0
- geneweb_py/api/routers/events.py +503 -0
- geneweb_py/api/routers/families.py +374 -0
- geneweb_py/api/routers/genealogy.py +515 -0
- geneweb_py/api/routers/organizations.py +150 -0
- geneweb_py/api/routers/persons.py +431 -0
- geneweb_py/api/routers/sessions.py +133 -0
- geneweb_py/api/serialization.py +344 -0
- geneweb_py/api/services/__init__.py +3 -0
- geneweb_py/api/services/export_bundle.py +30 -0
- geneweb_py/api/services/genealogy_service.py +1552 -0
- geneweb_py/api/services/organization_candidates.py +445 -0
- geneweb_py/api/services/organization_exports.py +223 -0
- geneweb_py/api/services/organization_tables.py +253 -0
- geneweb_py/api/session_store.py +132 -0
- geneweb_py/api/static/app.js +2393 -0
- geneweb_py/api/static/index.html +273 -0
- geneweb_py/api/static/style.css +860 -0
- geneweb_py/api/static/toldot-logo.png +0 -0
- geneweb_py/api/static/vendor/bootstrap.bundle.min.js +7 -0
- geneweb_py/api/static/vendor/bootstrap.min.css +6 -0
- geneweb_py/api/static/vendor/tabulator.min.css +2 -0
- geneweb_py/api/static/vendor/tabulator.min.js +3 -0
- geneweb_py/cli/__init__.py +9 -0
- geneweb_py/cli/commands.py +196 -0
- geneweb_py/cli/entry.py +15 -0
- geneweb_py/core/__init__.py +9 -0
- geneweb_py/core/date.py +620 -0
- geneweb_py/core/event.py +265 -0
- geneweb_py/core/event_to_organization.py +1099 -0
- geneweb_py/core/exceptions.py +648 -0
- geneweb_py/core/family.py +399 -0
- geneweb_py/core/genealogy.py +697 -0
- geneweb_py/core/models.py +42 -0
- geneweb_py/core/note_leads.py +191 -0
- geneweb_py/core/organization.py +209 -0
- geneweb_py/core/parser/__init__.py +39 -0
- geneweb_py/core/parser/gw_parser.py +3780 -0
- geneweb_py/core/parser/lexical.py +825 -0
- geneweb_py/core/parser/multipass_parser.py +65 -0
- geneweb_py/core/parser/place_tokens.py +173 -0
- geneweb_py/core/parser/reconcile.py +359 -0
- geneweb_py/core/parser/streaming.py +326 -0
- geneweb_py/core/parser/syntax.py +1668 -0
- geneweb_py/core/parser/wikitext.py +144 -0
- geneweb_py/core/parser/words.py +167 -0
- geneweb_py/core/person.py +365 -0
- geneweb_py/core/place.py +69 -0
- geneweb_py/core/shared_experiences.py +548 -0
- geneweb_py/core/validation.py +449 -0
- geneweb_py/formats/__init__.py +39 -0
- geneweb_py/formats/base.py +156 -0
- geneweb_py/formats/gedcom.py +1234 -0
- geneweb_py/formats/json.py +429 -0
- geneweb_py/formats/leads_table.py +158 -0
- geneweb_py/formats/org_sidecar.py +103 -0
- geneweb_py/formats/table_writers.py +229 -0
- geneweb_py/formats/tables.py +389 -0
- geneweb_py/formats/xml.py +808 -0
- geneweb_py/py.typed +0 -0
- geneweb_py-0.1.0.dist-info/METADATA +541 -0
- geneweb_py-0.1.0.dist-info/RECORD +86 -0
- geneweb_py-0.1.0.dist-info/WHEEL +5 -0
- geneweb_py-0.1.0.dist-info/entry_points.txt +2 -0
- geneweb_py-0.1.0.dist-info/licenses/LICENSE +21 -0
- geneweb_py-0.1.0.dist-info/top_level.txt +1 -0
geneweb_py/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
geneweb-py : Librairie Python pour le format GeneWeb (.gw)
|
|
3
|
+
|
|
4
|
+
Cette librairie permet de parser, manipuler et convertir les fichiers
|
|
5
|
+
généalogiques au format GeneWeb (.gw).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
__author__ = "Guillaume Cayeux"
|
|
10
|
+
__email__ = "guillaume.cayeux@relais4x100a2.fr"
|
|
11
|
+
|
|
12
|
+
from .core.models import Date, Event, Family, Genealogy, Person
|
|
13
|
+
from .core.parser import GeneWebParser, MultiPassParser
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Person",
|
|
17
|
+
"Family",
|
|
18
|
+
"Event",
|
|
19
|
+
"Date",
|
|
20
|
+
"Genealogy",
|
|
21
|
+
"GeneWebParser",
|
|
22
|
+
"MultiPassParser",
|
|
23
|
+
]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Réseau prosopographique : personnes, organisations et événements.
|
|
2
|
+
|
|
3
|
+
Remplace le script historique de ``Geneweb`` (V2, ``data_export/network.py``) qui
|
|
4
|
+
reconstruisait le graphe depuis des CSV. Ici le graphe est construit directement depuis
|
|
5
|
+
``Genealogy`` et ses ``Organization`` / ``OrganizationLink``.
|
|
6
|
+
|
|
7
|
+
La construction (``build_network``) et l'export JSON (vis.js) n'ont aucune dépendance.
|
|
8
|
+
``to_networkx``, GraphML, GEXF et Pyvis exigent l'extra ``analytics``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from itertools import combinations
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..core.genealogy import Genealogy
|
|
20
|
+
|
|
21
|
+
NODE_PERSON = "person"
|
|
22
|
+
NODE_ORG = "organization"
|
|
23
|
+
NODE_EVENT = "event"
|
|
24
|
+
|
|
25
|
+
EDGE_MEMBER = "member"
|
|
26
|
+
EDGE_PARTICIPANT = "participant"
|
|
27
|
+
EDGE_ORGANIZATION = "organization"
|
|
28
|
+
EDGE_WITNESS = "witness"
|
|
29
|
+
EDGE_SPOUSE = "spouse"
|
|
30
|
+
EDGE_PARENT = "parent"
|
|
31
|
+
EDGE_CO_MEMBER = "co_member"
|
|
32
|
+
|
|
33
|
+
PathLike = str | Path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require(module: str) -> Any:
|
|
37
|
+
try:
|
|
38
|
+
return importlib.import_module(module)
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise ImportError(
|
|
41
|
+
f"'{module}' est requis pour cet export : "
|
|
42
|
+
"installer l'extra avec `uv sync --extra analytics`."
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class NetworkNode:
|
|
48
|
+
id: str
|
|
49
|
+
label: str
|
|
50
|
+
kind: str
|
|
51
|
+
attrs: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class NetworkEdge:
|
|
56
|
+
source: str
|
|
57
|
+
target: str
|
|
58
|
+
kind: str
|
|
59
|
+
weight: int = 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Network:
|
|
64
|
+
"""Graphe non orienté, indépendant de toute bibliothèque."""
|
|
65
|
+
|
|
66
|
+
nodes: list[NetworkNode] = field(default_factory=list)
|
|
67
|
+
edges: list[NetworkEdge] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
def node(self, node_id: str) -> NetworkNode:
|
|
70
|
+
return next(n for n in self.nodes if n.id == node_id)
|
|
71
|
+
|
|
72
|
+
def edge(self, a: str, b: str) -> NetworkEdge:
|
|
73
|
+
"""Arête entre ``a`` et ``b`` (dans un sens ou dans l'autre)."""
|
|
74
|
+
return next(e for e in self.edges if {e.source, e.target} == {a, b})
|
|
75
|
+
|
|
76
|
+
def co_membership(self) -> "Network":
|
|
77
|
+
"""Projection sur les personnes : lien pondéré par le nombre d'organisations
|
|
78
|
+
partagées."""
|
|
79
|
+
members: dict[str, set[str]] = {}
|
|
80
|
+
for e in self.edges:
|
|
81
|
+
if e.kind == EDGE_MEMBER:
|
|
82
|
+
members.setdefault(e.target, set()).add(e.source)
|
|
83
|
+
shared: dict[tuple[str, str], int] = {}
|
|
84
|
+
for people in members.values():
|
|
85
|
+
for pair in combinations(sorted(people), 2):
|
|
86
|
+
shared[pair] = shared.get(pair, 0) + 1
|
|
87
|
+
used = {p for pair in shared for p in pair}
|
|
88
|
+
return Network(
|
|
89
|
+
nodes=[n for n in self.nodes if n.id in used],
|
|
90
|
+
edges=[
|
|
91
|
+
NetworkEdge(a, b, EDGE_CO_MEMBER, weight)
|
|
92
|
+
for (a, b), weight in shared.items()
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# -- Exports ----------------------------------------------------------
|
|
97
|
+
def export_json(self, path: PathLike) -> Path:
|
|
98
|
+
"""JSON ``{"nodes": [...], "edges": [...]}`` directement lisible par vis.js."""
|
|
99
|
+
payload = {
|
|
100
|
+
"nodes": [
|
|
101
|
+
{"id": n.id, "label": n.label, "group": n.kind, **n.attrs}
|
|
102
|
+
for n in self.nodes
|
|
103
|
+
],
|
|
104
|
+
"edges": [
|
|
105
|
+
{"from": e.source, "to": e.target, "value": e.weight, "title": e.kind}
|
|
106
|
+
for e in self.edges
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
target = Path(path)
|
|
110
|
+
target.write_text(
|
|
111
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
112
|
+
)
|
|
113
|
+
return target
|
|
114
|
+
|
|
115
|
+
def to_networkx(self) -> Any:
|
|
116
|
+
"""``networkx.Graph`` (attributs ``kind``, ``label``, ``weight``)."""
|
|
117
|
+
nx = _require("networkx")
|
|
118
|
+
graph = nx.Graph()
|
|
119
|
+
for n in self.nodes:
|
|
120
|
+
graph.add_node(n.id, label=n.label, kind=n.kind, **n.attrs)
|
|
121
|
+
for e in self.edges:
|
|
122
|
+
graph.add_edge(e.source, e.target, kind=e.kind, weight=e.weight)
|
|
123
|
+
return graph
|
|
124
|
+
|
|
125
|
+
def export_graphml(self, path: PathLike) -> Path:
|
|
126
|
+
nx = _require("networkx")
|
|
127
|
+
nx.write_graphml(self.to_networkx(), str(path), encoding="utf-8")
|
|
128
|
+
return Path(path)
|
|
129
|
+
|
|
130
|
+
def export_gexf(self, path: PathLike) -> Path:
|
|
131
|
+
nx = _require("networkx")
|
|
132
|
+
nx.write_gexf(self.to_networkx(), str(path), encoding="utf-8")
|
|
133
|
+
return Path(path)
|
|
134
|
+
|
|
135
|
+
def export_pyvis_html(self, path: PathLike, height: str = "750px") -> Path:
|
|
136
|
+
"""Page HTML autonome (bibliothèque embarquée, aucun accès réseau)."""
|
|
137
|
+
pyvis_network = _require("pyvis.network")
|
|
138
|
+
vis = pyvis_network.Network(
|
|
139
|
+
height=height, width="100%", cdn_resources="in_line", notebook=False
|
|
140
|
+
)
|
|
141
|
+
for n in self.nodes:
|
|
142
|
+
vis.add_node(n.id, label=n.label, group=n.kind)
|
|
143
|
+
for e in self.edges:
|
|
144
|
+
vis.add_edge(e.source, e.target, value=e.weight, title=e.kind)
|
|
145
|
+
target = Path(path)
|
|
146
|
+
vis.write_html(str(target), notebook=False)
|
|
147
|
+
return target
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _person_label(person: Any) -> str:
|
|
151
|
+
return f"{person.last_name} {person.first_name}".strip()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_network(
|
|
155
|
+
genealogy: "Genealogy",
|
|
156
|
+
include_events: bool = False,
|
|
157
|
+
include_families: bool = False,
|
|
158
|
+
) -> Network:
|
|
159
|
+
"""Construit le réseau prosopographique d'une généalogie.
|
|
160
|
+
|
|
161
|
+
Nœuds ``person:<id>`` et ``org:<id>`` ; arête ``member`` personne -> organisation
|
|
162
|
+
pondérée par le nombre de ``OrganizationLink``.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
include_events: ajoute un nœud par événement à l'origine d'un lien,
|
|
166
|
+
relié à son titulaire (``participant``), à l'organisation (``organization``)
|
|
167
|
+
et à ses témoins (``witness``).
|
|
168
|
+
include_families: ajoute les liens de parenté (``spouse``, ``parent``).
|
|
169
|
+
"""
|
|
170
|
+
network = Network()
|
|
171
|
+
node_ids: set[str] = set()
|
|
172
|
+
weights: dict[tuple[str, str, str], int] = {}
|
|
173
|
+
|
|
174
|
+
def add_node(node: NetworkNode) -> None:
|
|
175
|
+
if node.id not in node_ids:
|
|
176
|
+
node_ids.add(node.id)
|
|
177
|
+
network.nodes.append(node)
|
|
178
|
+
|
|
179
|
+
def add_edge(source: str, target: str, kind: str) -> None:
|
|
180
|
+
weights[(source, target, kind)] = weights.get((source, target, kind), 0) + 1
|
|
181
|
+
|
|
182
|
+
for person_id, person in genealogy.persons.items():
|
|
183
|
+
add_node(NetworkNode(f"person:{person_id}", _person_label(person), NODE_PERSON))
|
|
184
|
+
|
|
185
|
+
for org in genealogy.organizations.values():
|
|
186
|
+
attrs = {
|
|
187
|
+
"type": org.type.value,
|
|
188
|
+
"place": org.place,
|
|
189
|
+
"confidence": org.confidence,
|
|
190
|
+
"needs_review": org.needs_review,
|
|
191
|
+
}
|
|
192
|
+
add_node(
|
|
193
|
+
NetworkNode(
|
|
194
|
+
f"org:{org.id}",
|
|
195
|
+
org.name,
|
|
196
|
+
NODE_ORG,
|
|
197
|
+
{k: v for k, v in attrs.items() if v is not None},
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for link in genealogy.organization_links:
|
|
202
|
+
add_edge(f"person:{link.person_id}", f"org:{link.organization_id}", EDGE_MEMBER)
|
|
203
|
+
|
|
204
|
+
if include_events:
|
|
205
|
+
_add_events(genealogy, add_node, add_edge)
|
|
206
|
+
|
|
207
|
+
if include_families:
|
|
208
|
+
for family in genealogy.families.values():
|
|
209
|
+
if family.husband_id and family.wife_id:
|
|
210
|
+
add_edge(
|
|
211
|
+
f"person:{family.husband_id}",
|
|
212
|
+
f"person:{family.wife_id}",
|
|
213
|
+
EDGE_SPOUSE,
|
|
214
|
+
)
|
|
215
|
+
for child in family.children:
|
|
216
|
+
for parent_id in (family.husband_id, family.wife_id):
|
|
217
|
+
if parent_id:
|
|
218
|
+
add_edge(
|
|
219
|
+
f"person:{parent_id}",
|
|
220
|
+
f"person:{child.person_id}",
|
|
221
|
+
EDGE_PARENT,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
network.edges = [
|
|
225
|
+
NetworkEdge(source, target, kind, weight)
|
|
226
|
+
for (source, target, kind), weight in weights.items()
|
|
227
|
+
if source in node_ids and target in node_ids
|
|
228
|
+
]
|
|
229
|
+
return network
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _add_events(genealogy: "Genealogy", add_node: Any, add_edge: Any) -> None:
|
|
233
|
+
for person_id, person in genealogy.persons.items():
|
|
234
|
+
links = genealogy.get_organization_links(person_id=person_id)
|
|
235
|
+
for index, event in enumerate(person.events):
|
|
236
|
+
date = event.date.display_text if event.date else None
|
|
237
|
+
matching = [
|
|
238
|
+
link
|
|
239
|
+
for link in links
|
|
240
|
+
if link.source_event_type == event.event_type_raw
|
|
241
|
+
and (link.start_date.display_text if link.start_date else None) == date
|
|
242
|
+
]
|
|
243
|
+
if not matching:
|
|
244
|
+
continue
|
|
245
|
+
event_id = f"event:{person_id}:{index}"
|
|
246
|
+
label = " ".join(p for p in (event.event_type_raw, date) if p)
|
|
247
|
+
add_node(
|
|
248
|
+
NetworkNode(
|
|
249
|
+
event_id,
|
|
250
|
+
label,
|
|
251
|
+
NODE_EVENT,
|
|
252
|
+
{"place": event.place} if event.place else {},
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
add_edge(f"person:{person_id}", event_id, EDGE_PARTICIPANT)
|
|
256
|
+
for link in matching:
|
|
257
|
+
add_edge(event_id, f"org:{link.organization_id}", EDGE_ORGANIZATION)
|
|
258
|
+
for witness in event.witnesses:
|
|
259
|
+
witness_id = witness.get("person_id")
|
|
260
|
+
if witness_id:
|
|
261
|
+
add_edge(f"person:{witness_id}", event_id, EDGE_WITNESS)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module API FastAPI pour geneweb-py.
|
|
3
|
+
|
|
4
|
+
Ce module fournit une API REST moderne pour manipuler les données généalogiques
|
|
5
|
+
au format GeneWeb (.gw) avec FastAPI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .main import app, create_app
|
|
9
|
+
from .services.genealogy_service import GenealogyService
|
|
10
|
+
|
|
11
|
+
__all__ = ["app", "create_app", "GenealogyService"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Dépendances FastAPI pour l'API geneweb-py."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, Header, HTTPException, Request, Response, status
|
|
4
|
+
|
|
5
|
+
import geneweb_py.api.limits as _limits_mod
|
|
6
|
+
|
|
7
|
+
from .services.genealogy_service import GenealogyService
|
|
8
|
+
from .session_store import SessionStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_store(request: Request) -> SessionStore:
|
|
12
|
+
"""Retourne le SessionStore stocké dans app.state."""
|
|
13
|
+
return request.app.state.session_store
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Temps restant (secondes) renvoyé à chaque requête de session : le client recale son
|
|
17
|
+
# compte à rebours sans dépendre de son horloge.
|
|
18
|
+
SESSION_EXPIRES_IN_HEADER = "X-Session-Expires-In"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_session_service(
|
|
22
|
+
response: Response,
|
|
23
|
+
x_session_token: str = Header(..., alias="X-Session-Token"),
|
|
24
|
+
store: SessionStore = Depends(get_store),
|
|
25
|
+
) -> GenealogyService:
|
|
26
|
+
"""Résout le token de session (et prolonge son TTL) ; retourne son GenealogyService.
|
|
27
|
+
|
|
28
|
+
Le service est conservé avec la session : ses caches (index des candidates,
|
|
29
|
+
témoins) et ses réglages (mots ignorés) survivent d'une requête à l'autre.
|
|
30
|
+
"""
|
|
31
|
+
touched = store.service(x_session_token)
|
|
32
|
+
if touched is None:
|
|
33
|
+
raise HTTPException(
|
|
34
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
35
|
+
detail="Session inconnue ou expirée",
|
|
36
|
+
)
|
|
37
|
+
service, expires_at = touched
|
|
38
|
+
response.headers[SESSION_EXPIRES_IN_HEADER] = str(store.seconds_left(expires_at))
|
|
39
|
+
return service
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def require_write_mode() -> None:
|
|
43
|
+
"""Lève 405 si l'API est en mode lecture seule (READ_ONLY=true)."""
|
|
44
|
+
if _limits_mod.READ_ONLY:
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
47
|
+
detail="API en mode lecture seule",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_pagination_params(
|
|
52
|
+
page: int = 1, size: int = 20, max_size: int = 100
|
|
53
|
+
) -> tuple[int, int]:
|
|
54
|
+
"""Valide et retourne les paramètres de pagination."""
|
|
55
|
+
if page < 1:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
58
|
+
detail="Le numéro de page doit être supérieur à 0",
|
|
59
|
+
)
|
|
60
|
+
if size < 1 or size > max_size:
|
|
61
|
+
raise HTTPException(
|
|
62
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
63
|
+
detail=f"La taille de la page doit être entre 1 et {max_size}",
|
|
64
|
+
)
|
|
65
|
+
return page, size
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_search_limit(limit: int = 50, max_limit: int = 100) -> int:
|
|
69
|
+
"""Valide et retourne la limite de recherche."""
|
|
70
|
+
if limit < 1 or limit > max_limit:
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
73
|
+
detail=f"La limite doit être entre 1 et {max_limit}",
|
|
74
|
+
)
|
|
75
|
+
return limit
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Construction des charges utiles Family pour les réponses API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from ..core.family import Family
|
|
6
|
+
from .models.family import FamilyListSchema, FamilySchema
|
|
7
|
+
from .serialization import child_to_schema, date_to_api_string, stable_event_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _divorce_place_from_family(family: Family) -> str | None:
|
|
11
|
+
"""Lieu de divorce si stocké en métadonnées du parseur."""
|
|
12
|
+
if not family.metadata:
|
|
13
|
+
return None
|
|
14
|
+
raw = family.metadata.get("divorce_place")
|
|
15
|
+
if raw is None:
|
|
16
|
+
return None
|
|
17
|
+
text = str(raw).strip()
|
|
18
|
+
return text or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def family_to_family_schema(family: Family) -> FamilySchema:
|
|
22
|
+
"""Convertit une famille domaine en schéma API complet."""
|
|
23
|
+
src_list: list[str] = []
|
|
24
|
+
if family.family_source and str(family.family_source).strip():
|
|
25
|
+
src_list.append(str(family.family_source).strip())
|
|
26
|
+
|
|
27
|
+
event_ids: list[str] = []
|
|
28
|
+
for idx, ev in enumerate(family.events):
|
|
29
|
+
uid = getattr(ev, "unique_id", None)
|
|
30
|
+
if uid is not None:
|
|
31
|
+
event_ids.append(str(uid))
|
|
32
|
+
else:
|
|
33
|
+
event_ids.append(
|
|
34
|
+
stable_event_id(
|
|
35
|
+
ev, scope="family", scope_key=family.family_id, index=idx
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return FamilySchema(
|
|
40
|
+
id=family.family_id,
|
|
41
|
+
husband_id=family.husband_id,
|
|
42
|
+
wife_id=family.wife_id,
|
|
43
|
+
children=[child_to_schema(c) for c in family.children],
|
|
44
|
+
marriage_status=family.marriage_status,
|
|
45
|
+
notes=family.comments,
|
|
46
|
+
sources=src_list,
|
|
47
|
+
marriage_date=date_to_api_string(family.marriage_date),
|
|
48
|
+
marriage_place=family.marriage_place,
|
|
49
|
+
divorce_date=date_to_api_string(family.divorce_date),
|
|
50
|
+
divorce_place=_divorce_place_from_family(family),
|
|
51
|
+
events=event_ids,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def family_to_list_schema(family: Family) -> FamilyListSchema:
|
|
56
|
+
"""Schéma liste famille avec date de mariage."""
|
|
57
|
+
return FamilyListSchema(
|
|
58
|
+
id=family.family_id,
|
|
59
|
+
husband_id=family.husband_id,
|
|
60
|
+
wife_id=family.wife_id,
|
|
61
|
+
children_count=len(family.children),
|
|
62
|
+
marriage_date=date_to_api_string(family.marriage_date),
|
|
63
|
+
marriage_status=family.marriage_status,
|
|
64
|
+
)
|
geneweb_py/api/limits.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Limites et seuils de sécurité pour l'API HTTP.
|
|
3
|
+
|
|
4
|
+
Les valeurs peuvent être surchargées via les variables d'environnement
|
|
5
|
+
pour l'adaptation au déploiement.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
# Origines autorisées en développement / test local (frontends sur le port 3000).
|
|
11
|
+
DEFAULT_DEV_CORS_ORIGINS: list[str] = [
|
|
12
|
+
"http://127.0.0.1:3000",
|
|
13
|
+
"http://localhost:3000",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_int_env(name: str, default: int) -> int:
|
|
18
|
+
"""Lit une variable d'environnement entière avec repli sur défaut."""
|
|
19
|
+
raw = os.getenv(name)
|
|
20
|
+
if raw is None or raw.strip() == "":
|
|
21
|
+
return default
|
|
22
|
+
try:
|
|
23
|
+
return int(raw)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return default
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _split_csv_origins(raw: str) -> list[str]:
|
|
29
|
+
"""Découpe une chaîne d'origines séparées par des virgules."""
|
|
30
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_api_env() -> str:
|
|
34
|
+
"""Retourne le mode d'exécution API (dev, test, prod)."""
|
|
35
|
+
raw = os.getenv("GENEWEB_API_ENV", "dev")
|
|
36
|
+
return raw.strip().lower() or "dev"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_cors_allow_origins() -> list[str]:
|
|
40
|
+
"""
|
|
41
|
+
Retourne la liste des origines CORS autorisées selon l'environnement.
|
|
42
|
+
|
|
43
|
+
Priorité des variables d'environnement :
|
|
44
|
+
1. ``ALLOWED_ORIGINS`` (liste séparée par des virgules) ;
|
|
45
|
+
2. ``CORS_ORIGINS`` (rétrocompatibilité) ;
|
|
46
|
+
3. Si aucune n'est définie et que ``GENEWEB_API_ENV=prod`` : liste vide
|
|
47
|
+
(aucune origine cross-origin tant que l'opérateur ne configure pas
|
|
48
|
+
``ALLOWED_ORIGINS``) ;
|
|
49
|
+
4. Sinon : ``DEFAULT_DEV_CORS_ORIGINS``.
|
|
50
|
+
|
|
51
|
+
En production, une origine ``*`` est ignorée (incompatible avec
|
|
52
|
+
``allow_credentials=True`` et interdit par les critères de sécurité).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Liste d'URL d'origine sans slash final parasite.
|
|
56
|
+
"""
|
|
57
|
+
api_env = _get_api_env()
|
|
58
|
+
origins: list[str]
|
|
59
|
+
|
|
60
|
+
if "ALLOWED_ORIGINS" in os.environ:
|
|
61
|
+
raw = os.environ["ALLOWED_ORIGINS"]
|
|
62
|
+
if raw.strip() == "":
|
|
63
|
+
origins = []
|
|
64
|
+
else:
|
|
65
|
+
origins = _split_csv_origins(raw)
|
|
66
|
+
elif "CORS_ORIGINS" in os.environ:
|
|
67
|
+
raw = os.environ["CORS_ORIGINS"]
|
|
68
|
+
if raw.strip() == "":
|
|
69
|
+
origins = []
|
|
70
|
+
else:
|
|
71
|
+
origins = _split_csv_origins(raw)
|
|
72
|
+
elif api_env == "prod":
|
|
73
|
+
origins = []
|
|
74
|
+
else:
|
|
75
|
+
origins = list(DEFAULT_DEV_CORS_ORIGINS)
|
|
76
|
+
|
|
77
|
+
if api_env == "prod":
|
|
78
|
+
origins = [o for o in origins if o != "*"]
|
|
79
|
+
|
|
80
|
+
return origins
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
MAX_UPLOAD_BYTES: int = _parse_int_env("GENEWEB_MAX_UPLOAD_BYTES", 50 * 1024 * 1024)
|
|
84
|
+
|
|
85
|
+
SESSION_MAX_SESSIONS: int = _parse_int_env("SESSION_MAX_SESSIONS", 10)
|
|
86
|
+
SESSION_TTL_SECONDS: int = _parse_int_env("SESSION_TTL_SECONDS", 3600)
|
|
87
|
+
READ_ONLY: bool = os.getenv("READ_ONLY", "false").strip().lower() == "true"
|