2626from django .conf import settings
2727from elasticsearch8 .exceptions import NotFoundError
2828from elasticsearch8 import Elasticsearch as Elastic8Client
29- from elasticsearch8 .dsl import (
30- Document ,
31- connections ,
32- ComposableIndexTemplate ,
33- mapped_field ,
34- Keyword ,
35- )
29+ from elasticsearch8 import dsl as esdsl
3630from elasticsearch8 .dsl ._sync .document import IndexMeta
37- from elasticsearch8 .dsl .document_base import DocumentOptions
3831
3932from elasticsearch_metrics import signals
4033from elasticsearch_metrics import exceptions
4134from elasticsearch_metrics .registry import djelme_registry
42- from elasticsearch_metrics .protocols import ProtoDjelmeBackend , ProtoCountedUsage
35+ from elasticsearch_metrics .protocols import (
36+ ProtoDjelmeBackend ,
37+ ProtoCountedUsage ,
38+ ProtoDjelmeRecord ,
39+ )
4340from elasticsearch_metrics .util import timeseries_naming
4441from elasticsearch_metrics .util .unique_together import get_unique_id
4542from elasticsearch_metrics .util .anon_enough import opaque_sessionhour_id
5552# invasive hacky changes to elasticsearch8.dsl
5653
5754# change default mapping for `str` annotations from Text to Keyword:
58- DocumentOptions .type_annotation_map [str ] = (Keyword , {})
55+ esdsl . document_base . DocumentOptions .type_annotation_map [str ] = (esdsl . Keyword , {})
5956
6057
6158# changes to document metaclass behavior
@@ -117,7 +114,7 @@ def app_label(self) -> str | None:
117114 return djelme_registry .get_recordtype_app_label (self )
118115
119116
120- class DjelmeRecordtype (Document , metaclass = _DjelmeRecordtypeMetaclass ):
117+ class DjelmeRecordtype (esdsl . Document , metaclass = _DjelmeRecordtypeMetaclass ):
121118 """a subclass of elasticsearch8.dsl.Document, with conveniences
122119
123120 >>> class MyAbstractRecord(DjelmeRecordtype):
@@ -145,24 +142,28 @@ class DjelmeRecordtype(Document, metaclass=_DjelmeRecordtypeMetaclass):
145142 """
146143
147144 UNIQUE_TOGETHER_FIELDS : typing .ClassVar [collections .abc .Iterable [str ]] = ()
148- unique_id : str = mapped_field (Keyword (), default = "" ) # filled on save
145+ unique_id : str = esdsl . mapped_field (esdsl . Keyword (), default = "" ) # filled on save
149146
150147 class Meta :
151148 abstract = True
152149
153150 @classmethod
154- def record (cls , * , using = None , ** kwargs ):
151+ def record (
152+ cls , * , using : str | None = None , ** kwargs : typing .Any
153+ ) -> "typing.Self" : # typing.Self added in py 3.11 -- str annotation until 3.10 eol
155154 """Persist a record in Elasticsearch."""
156155 _instance = cls (** kwargs )
157156 _instance .save (using = using )
158157 return _instance
159158
160159 @classmethod
161- def check_djelme_setup (cls , using : str | Elastic8Client | None = None ) -> bool :
160+ def check_djelme_setup (cls , using : str | None = None ) -> bool :
161+ # this base class has only a single index -- does it exist?
162162 return bool (cls ._index .get (using = using ))
163163
164164 @classmethod
165165 def _djelme_teardown (cls , es_client ):
166+ # this base class has only a single index -- delete it
166167 cls ._index .delete (using = es_client )
167168
168169 @classmethod
@@ -229,9 +230,14 @@ def _get_unique_id(self) -> str | None:
229230
230231
231232class TimeseriesRecord (DjelmeRecordtype ):
232- timestamp : datetime .datetime = mapped_field (
233+ timestamp : datetime .datetime = esdsl . mapped_field (
233234 default_factory = lambda : django .utils .timezone .now ()
234235 )
236+ # the 'version' field type allows range queries on semver-like strings
237+ # that fit perfectly with "timeparts" representation of a UTC datetime
238+ # as a sequence of integers -- helps to avoid time zones and date math
239+ # (e.g. '2000' < '2000.5.10' < '2000.5.20.20.20' < '2000.11' < '2001')
240+ timestamp_parts : str = esdsl .mapped_field (esdsl .Version (), default = "" )
235241
236242 class Meta :
237243 abstract = True
@@ -240,18 +246,57 @@ class Meta:
240246 # class methods
241247
242248 @classmethod
243- def init (cls , index = None , using = None ):
244- """Create the index and populate the mappings in elasticsearch.
249+ def init (cls , index = None , using = None ) -> None :
250+ """Create an index template with mappings for timeseries indexes
245251
246252 overrides elasticsearch.Document.init
253+ (but doesn't call super().init(), which would create a "now" index)
247254 """
248255 assert not cls .is_abstract
249- # to init timeseries indexes, create only the template
250256 cls .sync_index_template (using = using )
251- return super ().init (
252- index = (index or cls .format_timeseries_index_name ()),
253- using = cls ._get_using (using ),
257+
258+ @classmethod
259+ def search (cls , * , index = None , ** kwargs ):
260+ return super ().search (
261+ index = (index or cls .format_timeseries_index_pattern ()),
262+ ** kwargs ,
263+ )
264+
265+ @classmethod
266+ def search_timeseries_range (
267+ cls ,
268+ from_when : tuple [int , ...] | datetime .date ,
269+ until_when : tuple [int , ...] | datetime .date ,
270+ ** kwargs : typing .Any ,
271+ ) -> typing .Any :
272+ _index_pattern = cls .format_timeseries_index_pattern_for_range (
273+ from_when , until_when
274+ )
275+ _timestamp_q = esdsl .query .Range (
276+ timestamp_parts = {
277+ "gte" : timeseries_naming .full_semverlike_timeparts (from_when ),
278+ "lt" : timeseries_naming .full_semverlike_timeparts (until_when ),
279+ }
254280 )
281+ return cls .search (index = _index_pattern ).filter (_timestamp_q )
282+
283+ @classmethod
284+ def refresh_timeseries_indexes (cls , using : str | None = None ) -> None :
285+ cls ._get_connection (using ).indices .refresh (
286+ index = cls .format_timeseries_index_pattern ()
287+ )
288+
289+ @classmethod
290+ def each_timeseries_index (
291+ cls , using : str | None = None
292+ ) -> collections .abc .Iterator [tuple [str , dict [str , typing .Any ]]]:
293+ _resp = cls ._get_connection (using ).indices .get (
294+ index = cls .format_timeseries_index_pattern (),
295+ )
296+ for _index_name , _index_info in _resp .items ():
297+ assert isinstance (_index_name , str )
298+ assert isinstance (_index_info , dict )
299+ yield _index_name , _index_info
255300
256301 @classmethod
257302 def _djelme_teardown (cls , es8_client : Elastic8Client ) -> None :
@@ -277,7 +322,7 @@ def format_timeseries_index_name(
277322 )
278323
279324 @classmethod
280- def get_timeseries_template (cls ) -> ComposableIndexTemplate :
325+ def get_timeseries_template (cls ) -> esdsl . ComposableIndexTemplate :
281326 return cls ._index .as_composable_template (
282327 template_name = cls .get_timeseries_template_name (),
283328 pattern = cls .format_timeseries_index_pattern (),
@@ -312,6 +357,20 @@ def format_timeseries_index_pattern(cls, timeparts: tuple[int, ...] = ()) -> str
312357 max_timedepth = cls .get_timedepth (),
313358 )
314359
360+ @classmethod
361+ def format_timeseries_index_pattern_for_range (
362+ cls ,
363+ from_when : tuple [int , ...] | datetime .date ,
364+ until_when : tuple [int , ...] | datetime .date ,
365+ ) -> str :
366+ return timeseries_naming .format_index_pattern_for_range (
367+ cls .get_timeseries_name_prefix (),
368+ cls .get_timeseries_recordtype_name (),
369+ from_when ,
370+ until_when ,
371+ timedepth = cls .get_timedepth (),
372+ )
373+
315374 @classmethod
316375 def get_timedepth (cls ) -> int :
317376 _default_timedepth = getattr (
@@ -404,6 +463,13 @@ def check_djelme_setup(cls, using: str | Elastic8Client | None = None) -> bool:
404463 ###
405464 # instance methods
406465
466+ def __init__ (self , * args , ** kwargs ):
467+ super ().__init__ (* args , ** kwargs )
468+ self .timestamp_parts = self ._build_timestamp_parts ()
469+
470+ def _build_timestamp_parts (self ) -> str :
471+ return timeseries_naming .full_semverlike_timeparts (self .timestamp )
472+
407473 def djelme_index_name (self ) -> str :
408474 assert self .timestamp is not None
409475 return self .format_timeseries_index_name (self .timestamp )
@@ -444,11 +510,11 @@ class CountedUsageRecord(EventRecord):
444510 """
445511
446512 # for ProtoCountedUsage:
447- platform_iri : str = mapped_field (required = True , default = "" )
448- database_iri : str = mapped_field (required = True , default = "" )
449- item_iri : str = mapped_field (required = True , default = "" )
450- sessionhour_id : str = mapped_field (Keyword (), default = "" )
451- within_iris : list [str ] = mapped_field (Keyword (), default_factory = list )
513+ platform_iri : str = esdsl . mapped_field (required = True , default = "" )
514+ database_iri : str = esdsl . mapped_field (required = True , default = "" )
515+ item_iri : str = esdsl . mapped_field (required = True , default = "" )
516+ sessionhour_id : str = esdsl . mapped_field (esdsl . Keyword (), default = "" )
517+ within_iris : list [str ] = esdsl . mapped_field (esdsl . Keyword (), default_factory = list )
452518
453519 class Meta :
454520 abstract = True
@@ -457,9 +523,9 @@ class Meta:
457523 def record (
458524 cls ,
459525 * ,
460- # each usage record needs a sessionhour_id -- for migrating old data, can set explicitly
526+ # each usage record needs a sessionhour_id -- for migrating old data, can set explicitly...
461527 sessionhour_id : str = "" ,
462- # ...but when saving new data, give either the dirty identifying strings
528+ # ...but when saving new data, give either the dirty identifying strings:
463529 user_id : str = "" ,
464530 session_id : str = "" ,
465531 request_host : str = "" ,
@@ -518,7 +584,7 @@ def djelme_imp_kwargs(self) -> dict[str, str]: # for ProtoDjelmeBackend
518584 @property
519585 def elastic8_client (self ) -> Elastic8Client :
520586 # assumes `connections.configure` was already called
521- return connections .get_connection (self ._elastic8dsl_connection_name )
587+ return esdsl . connections .get_connection (self ._elastic8dsl_connection_name )
522588
523589 @property
524590 def _elastic8dsl_connection_name (self ) -> str :
@@ -546,6 +612,7 @@ def djelme_teardown(self, recordtypes: collections.abc.Iterable[type]) -> None:
546612 # for static type-checking; verify intent
547613 _ : type [ProtoCountedUsage ] = CountedUsageRecord
548614 __ : type [ProtoDjelmeBackend ] = DjelmeElastic8Backend
615+ ___ : type [ProtoDjelmeRecord ] = DjelmeRecordtype
549616
550617###
551618# names expected by ProtoDjelmeImp
@@ -556,7 +623,7 @@ def djelme_teardown(self, recordtypes: collections.abc.Iterable[type]) -> None:
556623def djelme_when_ready ( # for ProtoDjelmeImp
557624 backends : collections .abc .Iterable [ProtoDjelmeBackend ],
558625) -> None :
559- connections .configure (
626+ esdsl . connections .configure (
560627 ** {
561628 _backend ._elastic8dsl_connection_name : _backend ._elastic8dsl_connection_kwargs
562629 for _backend in backends
0 commit comments