Skip to content

Commit ab8d39a

Browse files
authored
Add DNA segment map endpoint (#427)
* Add DNA segment endpoint * Fix int type * Add ancestor profiles to dna
1 parent 3fcbeb2 commit ab8d39a

File tree

4 files changed

+651
-0
lines changed

4 files changed

+651
-0
lines changed

gramps_webapi/api/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from .resources.notes import NoteResource, NotesResource
6161
from .resources.objects import CreateObjectsResource
6262
from .resources.people import PeopleResource, PersonResource
63+
from .resources.dna import PersonDnaMatchesResource
6364
from .resources.places import PlaceResource, PlacesResource
6465
from .resources.relations import RelationResource, RelationsResource
6566
from .resources.reports import (
@@ -133,6 +134,11 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
133134
PersonTimelineResource, "/people/<string:handle>/timeline", "person-timeline"
134135
)
135136
register_endpt(PersonResource, "/people/<string:handle>", "person")
137+
register_endpt(
138+
PersonDnaMatchesResource,
139+
"/people/<string:handle>/dna/matches",
140+
"person-dna-matches",
141+
)
136142
register_endpt(PeopleResource, "/people/", "people")
137143
# Families
138144
register_endpt(

gramps_webapi/api/resources/dna.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#
2+
# Gramps Web API - A RESTful API for the Gramps genealogy program
3+
#
4+
# Copyright (C) 2020 Nick Hall
5+
# Copyright (C) 2020-2023 Gary Griffin
6+
# Copyright (C) 2023 David Straub
7+
#
8+
# This program is free software; you can redistribute it and/or modify
9+
# it under the terms of the GNU Affero General Public License as published by
10+
# the Free Software Foundation; either version 3 of the License, or
11+
# (at your option) any later version.
12+
#
13+
# This program is distributed in the hope that it will be useful,
14+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
# GNU Affero General Public License for more details.
17+
#
18+
# You should have received a copy of the GNU Affero General Public License
19+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
20+
#
21+
22+
"""DNA resources."""
23+
24+
from typing import Any, Dict, List, Optional, Union
25+
26+
from flask import abort
27+
from gramps.gen.const import GRAMPS_LOCALE as glocale
28+
from gramps.gen.db.base import DbReadBase
29+
from gramps.gen.errors import HandleError
30+
from gramps.gen.lib import Person, PersonRef
31+
from gramps.gen.relationship import get_relationship_calculator
32+
from gramps.gen.utils.grampslocale import GrampsLocale
33+
from webargs import fields, validate
34+
35+
from ...types import Handle
36+
from ..util import get_db_handle, get_locale_for_language, use_args
37+
from .util import get_person_profile_for_handle
38+
from . import ProtectedResource
39+
40+
SIDE_UNKNOWN = "U"
41+
SIDE_MATERNAL = "M"
42+
SIDE_PATERNAL = "P"
43+
44+
Segment = Dict[str, Union[float, int, str]]
45+
46+
47+
class PersonDnaMatchesResource(ProtectedResource):
48+
"""Resource for getting DNA match data for a person."""
49+
50+
@use_args(
51+
{
52+
"locale": fields.Str(
53+
load_default=None, validate=validate.Length(min=2, max=5)
54+
),
55+
},
56+
location="query",
57+
)
58+
def get(self, args: Dict, handle: str):
59+
"""Get the DNA match data."""
60+
db_handle = get_db_handle()
61+
try:
62+
person = db_handle.get_person_from_handle(handle)
63+
except HandleError:
64+
abort(404)
65+
locale = get_locale_for_language(args["locale"], default=True)
66+
matches = []
67+
for association in person.get_person_ref_list():
68+
if association.get_relation() == "DNA":
69+
match_data = get_match_data(
70+
db_handle=db_handle,
71+
person=person,
72+
association=association,
73+
locale=locale,
74+
)
75+
matches.append(match_data)
76+
return matches
77+
78+
79+
def get_match_data(
80+
db_handle: DbReadBase,
81+
person: Person,
82+
association: PersonRef,
83+
locale: GrampsLocale = glocale,
84+
) -> Dict[str, Any]:
85+
"""Get the DNA match data in the appropriate format."""
86+
relationship = get_relationship_calculator(reinit=True, clocale=locale)
87+
associate = db_handle.get_person_from_handle(association.ref)
88+
data, _ = relationship.get_relationship_distance_new(
89+
db_handle,
90+
person,
91+
associate,
92+
all_families=False,
93+
all_dist=True,
94+
only_birth=True,
95+
)
96+
if data[0][0] <= 0: # Unrelated
97+
side = SIDE_UNKNOWN
98+
elif data[0][0] == 1: # parent / child
99+
if db_handle.get_person_from_handle(data[0][1]).gender == 0:
100+
side = SIDE_MATERNAL
101+
else:
102+
side = SIDE_PATERNAL
103+
elif (
104+
len(data) > 1 and data[0][0] == data[1][0] and data[0][2][0] != data[1][2][0]
105+
): # shares both parents
106+
side = SIDE_UNKNOWN
107+
else:
108+
translate_sides = {"m": SIDE_MATERNAL, "f": SIDE_PATERNAL}
109+
side = translate_sides[data[0][2][0]]
110+
111+
segments = []
112+
113+
# Get Notes attached to Association
114+
note_handles = association.get_note_list()
115+
116+
# Get Notes attached to Citation which is attached to the Association
117+
for citation_handle in association.get_citation_list():
118+
citation = db_handle.get_citation_from_handle(citation_handle)
119+
note_handles += citation.get_note_list()
120+
121+
for note_handle in note_handles:
122+
segments += get_segments_from_note(db_handle, handle=note_handle, side=side)
123+
124+
rel_strings, common_ancestors = relationship.get_all_relationships(
125+
db_handle, person, associate
126+
)
127+
if len(rel_strings) == 0:
128+
rel_string = ""
129+
ancestor_handles = []
130+
else:
131+
rel_string = rel_strings[0]
132+
ancestor_handles = common_ancestors[0]
133+
ancestor_profiles = [
134+
get_person_profile_for_handle(
135+
db_handle=db_handle, handle=handle, args=[], locale=locale
136+
)
137+
for handle in ancestor_handles
138+
]
139+
return {
140+
"handle": association.ref,
141+
"segments": segments,
142+
"relation": rel_string,
143+
"ancestor_handles": ancestor_handles,
144+
"ancestor_profiles": ancestor_profiles,
145+
}
146+
147+
148+
def get_segments_from_note(
149+
db_handle: DbReadBase, handle: Handle, side: Optional[str] = None
150+
) -> List[Segment]:
151+
"""Get the segements from a note handle."""
152+
note = db_handle.get_note_from_handle(handle)
153+
segments = []
154+
for line in note.get().split("\n"):
155+
data = parse_line(line, side=side)
156+
if data:
157+
segments.append(data)
158+
return segments
159+
160+
161+
def parse_line(line: str, side: Optional[str] = None) -> Optional[Segment]:
162+
"""Parse a line from the CSV/TSV data and return a dictionary."""
163+
if "\t" in line:
164+
# Tabs are the field separators. Now determine THOUSEP and RADIXCHAR.
165+
# Use Field 2 (Stop Pos) to see if there are THOUSEP there. Use Field 3
166+
# (SNPs) to see if there is a radixchar
167+
field = line.split("\t")
168+
if "," in field[2]:
169+
line = line.replace(",", "")
170+
elif "." in field[2]:
171+
line = line.replace(".", "")
172+
if "," in field[3]:
173+
line = line.replace(",", ".")
174+
line = line.replace("\t", ",")
175+
field = line.split(",")
176+
if len(field) < 4:
177+
return None
178+
chromo = field[0].strip()
179+
start = get_base(field[1])
180+
stop = get_base(field[2])
181+
try:
182+
cms = float(field[3])
183+
except (ValueError, TypeError, IndexError):
184+
return None
185+
try:
186+
snp = int(field[4])
187+
except (ValueError, TypeError, IndexError):
188+
snp = 0
189+
seg_comment = ""
190+
side = side or SIDE_UNKNOWN
191+
if len(field) > 5:
192+
if field[5] in {SIDE_MATERNAL, SIDE_PATERNAL, SIDE_UNKNOWN}:
193+
side = field[5].strip()
194+
else:
195+
seg_comment = field[5].strip()
196+
return {
197+
"chromosome": chromo,
198+
"start": start,
199+
"stop": stop,
200+
"side": side,
201+
"cM": cms,
202+
"SNPs": snp,
203+
"comment": seg_comment,
204+
}
205+
206+
207+
def get_base(num: str) -> int:
208+
"""Get the number as int."""
209+
try:
210+
return int(num)
211+
except (ValueError, TypeError):
212+
try:
213+
return int(float(num) * 1000000)
214+
except (ValueError, TypeError):
215+
return 0

gramps_webapi/data/apispec.yaml

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,40 @@ paths:
12651265
description: "Unprocessable Entity: Invalid or bad parameter provided."
12661266

12671267

1268+
1269+
/people/{handle}/dna/matches:
1270+
get:
1271+
tags:
1272+
- people
1273+
summary: "Get DNA matches for a specific person."
1274+
operationId: getPersonDnaMatches
1275+
security:
1276+
- Bearer: []
1277+
parameters:
1278+
- name: handle
1279+
in: path
1280+
required: true
1281+
type: string
1282+
minLength: 8
1283+
description: "The unique identifier for a person."
1284+
- name: locale
1285+
in: query
1286+
required: false
1287+
type: string
1288+
description: "Specifies the locale to be used where applicable if one other than the current default is desired. Should be a valid language code from the available list of translations."
1289+
responses:
1290+
200:
1291+
description: "OK: Successful operation."
1292+
schema:
1293+
type: array
1294+
items:
1295+
$ref: "#/definitions/DnaMatch"
1296+
401:
1297+
description: "Unauthorized: Missing authorization header."
1298+
422:
1299+
description: "Unprocessable Entity: Invalid or bad parameter provided."
1300+
1301+
12681302
##############################################################################
12691303
# Endpoint - Families
12701304
##############################################################################
@@ -10130,6 +10164,77 @@ definitions:
1013010164
type: string
1013110165
example: "Birth"
1013210166

10167+
10168+
##############################################################################
10169+
# Model - DnaMatch
10170+
##############################################################################
10171+
10172+
DnaMatch:
10173+
type: object
10174+
properties:
10175+
handle:
10176+
description: The handle of the matching person.
10177+
type: string
10178+
example: 9BXKQC1PVLPYFMD6IX
10179+
relation:
10180+
description: The relationship to the matching person
10181+
type: string
10182+
example: "first cousin"
10183+
ancestor_handles:
10184+
description: The handles of the latest common ancestors
10185+
type: array
10186+
items:
10187+
type: string
10188+
example: ORFKQC4KLWEGTGR19L
10189+
ancestor_profiles:
10190+
description: The profiles of the latest common ancestors
10191+
type: array
10192+
items:
10193+
type: array
10194+
items:
10195+
$ref: "#/definitions/PersonProfile"
10196+
segments:
10197+
description: Details about each maching chromosome segment.
10198+
type: array
10199+
items:
10200+
$ref: "#/definitions/DnaSegment"
10201+
10202+
10203+
##############################################################################
10204+
# Model - DnaSegment
10205+
##############################################################################
10206+
10207+
DnaSegment:
10208+
type: object
10209+
properties:
10210+
chromosome:
10211+
description: The handle of the matching person.
10212+
type: string
10213+
example: "11"
10214+
start:
10215+
description: The starting number for the segment location.
10216+
type: integer
10217+
example: 56950055
10218+
stop:
10219+
description: The ending number for the segment location.
10220+
type: integer
10221+
example: 64247327
10222+
side:
10223+
description: Whether the match is one the maternal (M), paternal (P), or unkown (U) side.
10224+
type: string
10225+
example: P
10226+
cM:
10227+
description: The Genetic Distance (otherwise known as the number of centiMorgans) in the segment.
10228+
type: number
10229+
example: 10.9
10230+
SNPs:
10231+
description: Number of matching SNPs (Single Nucleotide Polymorphisms) in the segment.
10232+
type: integer
10233+
example: 1404
10234+
comment:
10235+
description: A comment about the segment
10236+
type: string
10237+
1013310238
##############################################################################
1013410239
# Model - TimelinePersonProfile
1013510240
##############################################################################

0 commit comments

Comments
 (0)