ocelescope-module-ocel 0.1.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 (25) hide show
  1. ocelescope_module_ocel-0.1.0/PKG-INFO +17 -0
  2. ocelescope_module_ocel-0.1.0/README.md +6 -0
  3. ocelescope_module_ocel-0.1.0/pyproject.toml +27 -0
  4. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/__init__.py +0 -0
  5. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/models/__init__.py +30 -0
  6. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/models/attributes.py +32 -0
  7. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/models/metadata.py +31 -0
  8. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/models/quantities.py +22 -0
  9. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/models/relations.py +42 -0
  10. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/models/time.py +13 -0
  11. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/models/variants.py +33 -0
  12. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/module.py +19 -0
  13. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/__init__.py +28 -0
  14. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/attributes.py +80 -0
  15. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/events.py +104 -0
  16. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/export.py +108 -0
  17. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/management.py +117 -0
  18. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/objects.py +56 -0
  19. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/quantities.py +15 -0
  20. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/routers/relations.py +83 -0
  21. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/util/__init__.py +0 -0
  22. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/util/attributes.py +295 -0
  23. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/util/pagination.py +35 -0
  24. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/util/relations.py +213 -0
  25. ocelescope_module_ocel-0.1.0/src/ocelescope_module_ocel/util/variants.py +48 -0
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: ocelescope-module-ocel
3
+ Version: 0.1.0
4
+ Summary: Ocelescope backend module exposing OCEL inspection routes
5
+ Requires-Dist: fastapi>=0.136.0
6
+ Requires-Dist: ocelescope>=0.4.0,<0.5.0
7
+ Requires-Dist: ocelescope-backend>=0.2.0,<0.3.0
8
+ Requires-Dist: polars>=1.40
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ocelescope-module-ocel
13
+
14
+ Ocelescope backend module exposing the OCEL inspection API (metadata, attributes,
15
+ object/event summaries, relation counts, variants, quantities and export).
16
+
17
+ Mounted at `/modules/ocel/v1`.
@@ -0,0 +1,6 @@
1
+ # ocelescope-module-ocel
2
+
3
+ Ocelescope backend module exposing the OCEL inspection API (metadata, attributes,
4
+ object/event summaries, relation counts, variants, quantities and export).
5
+
6
+ Mounted at `/modules/ocel/v1`.
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "ocelescope-module-ocel"
3
+ version = "0.1.0"
4
+ description = "Ocelescope backend module exposing OCEL inspection routes"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "fastapi>=0.136.0",
9
+ "ocelescope>=0.4.0,<0.5.0",
10
+ "ocelescope-backend>=0.2.0,<0.3.0",
11
+ "polars>=1.40",
12
+ ]
13
+
14
+ [project.entry-points."ocelescope_backend.modules"]
15
+ ocelV1 = "ocelescope_module_ocel.module:Ocel"
16
+
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.11.20,<0.12.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [tool.uv]
23
+ package = true
24
+
25
+ [tool.uv.sources]
26
+ ocelescope = { workspace = true }
27
+ ocelescope-backend = { workspace = true }
@@ -0,0 +1,30 @@
1
+ from ocelescope_module_ocel.models.attributes import (
2
+ AggregatedAttribute,
3
+ Attribute,
4
+ TypedAttribute,
5
+ )
6
+ from ocelescope_module_ocel.models.metadata import OcelMetadata
7
+ from ocelescope_module_ocel.models.quantities import QuantityInfo
8
+ from ocelescope_module_ocel.models.relations import (
9
+ RelationCombination,
10
+ RelationCountSummary,
11
+ )
12
+ from ocelescope_module_ocel.models.time import (
13
+ Date_Distribution_Item,
14
+ Entity_Time_Info,
15
+ )
16
+ from ocelescope_module_ocel.models.variants import ObjectTypeVariants, ObjectVariant
17
+
18
+ __all__ = [
19
+ "AggregatedAttribute",
20
+ "Attribute",
21
+ "TypedAttribute",
22
+ "OcelMetadata",
23
+ "QuantityInfo",
24
+ "RelationCombination",
25
+ "RelationCountSummary",
26
+ "Date_Distribution_Item",
27
+ "Entity_Time_Info",
28
+ "ObjectTypeVariants",
29
+ "ObjectVariant",
30
+ ]
@@ -0,0 +1,32 @@
1
+ from enum import StrEnum
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class ValueType(StrEnum):
7
+ EMPTY = "empty"
8
+ STRING = "string"
9
+ BOOL = "bool"
10
+ INT = "int"
11
+ FLOAT = "float"
12
+ DATE = "date"
13
+
14
+
15
+ class Attribute(BaseModel):
16
+ name: str
17
+ min: str | int | float
18
+ max: str | int | float
19
+ distinct_values: int
20
+ type: ValueType
21
+
22
+
23
+ class AggregatedAttribute(Attribute):
24
+ """An attribute summarized across every entity type that carries it."""
25
+
26
+ entity_type_names: list[str]
27
+
28
+
29
+ class TypedAttribute(Attribute):
30
+ """An attribute summarized for a single entity type (activity / object type)."""
31
+
32
+ entity_type: str
@@ -0,0 +1,31 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from ocelescope_backend.app.internal.registry import registry_manager
4
+ from ocelescope_backend.app.internal.registry.extension import OCELExtensionDescription
5
+ from pydantic import BaseModel
6
+
7
+ if TYPE_CHECKING:
8
+ from ocelescope_backend.app.internal.model.ocel import SessionOCEL
9
+
10
+
11
+ class OcelMetadata(BaseModel):
12
+ id: str
13
+ name: str
14
+ created_at: str
15
+ extensions: list[OCELExtensionDescription]
16
+ filter_applied: bool | None
17
+
18
+ @classmethod
19
+ def from_handle(cls, handle: "SessionOCEL", filter_applied: bool | None = None):
20
+ descriptions = registry_manager.get_extension_descriptions()
21
+ return cls(
22
+ id=handle.id,
23
+ created_at=handle.created_at,
24
+ name=handle.name,
25
+ extensions=[
26
+ descriptions[extension.__class__.__name__]
27
+ for extension in handle.extensions
28
+ if extension.__class__.__name__ in descriptions
29
+ ],
30
+ filter_applied=filter_applied,
31
+ )
@@ -0,0 +1,22 @@
1
+ from typing import Self
2
+
3
+ from ocelescope import OCEL
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class QuantityInfo(BaseModel):
8
+ item_types: list[str]
9
+ total_object_count: int
10
+ total_event_count: int
11
+ object_types: list[str]
12
+ activities: list[str]
13
+
14
+ @classmethod
15
+ def from_ocel(cls, ocel: OCEL) -> Self:
16
+ return cls(
17
+ item_types=ocel.quantities.item_types,
18
+ total_object_count=len(ocel.quantities.objects),
19
+ total_event_count=len(ocel.quantities.events),
20
+ object_types=ocel.quantities.object_types,
21
+ activities=ocel.quantities.activities,
22
+ )
@@ -0,0 +1,42 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class RelationCombination(BaseModel):
5
+ """A distinct (source, target, qualifier) combination of a relation.
6
+
7
+ Attributes:
8
+ source: The source object or event type.
9
+ target: The target object or event type.
10
+ qualifier: The relation qualifier (empty string when none applies).
11
+ """
12
+
13
+ source: str
14
+ target: str
15
+ qualifier: str
16
+
17
+
18
+ class RelationCountSummary(BaseModel):
19
+ """Summary statistics for relationship multiplicities in O2O or E2O relations.
20
+
21
+ For each (source type, target type[, qualifier]) combination, captures how many
22
+ related items occur per source instance.
23
+
24
+ Attributes:
25
+ qualifier: The relation qualifier (empty string when none applies).
26
+ qualifiers: The qualifiers aggregated under a (source, target) pair. For
27
+ aggregated rows (``with_qualifier=False``) this lists every qualifier;
28
+ for per-qualifier rows it is the single-element list ``[qualifier]``.
29
+ source: The source object or event type.
30
+ target: The target object or event type.
31
+ min_count: The minimum number of related items for any source instance.
32
+ max_count: The maximum number of related items for any source instance.
33
+ sum: The total number of relation occurrences across all instances.
34
+ """
35
+
36
+ qualifier: str
37
+ qualifiers: list[str] = []
38
+ source: str
39
+ target: str
40
+ min_count: int
41
+ max_count: int
42
+ sum: int
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Date_Distribution_Item(BaseModel):
5
+ start_timestamp: str
6
+ end_timestamp: str
7
+ entity_count: dict[str, int]
8
+
9
+
10
+ class Entity_Time_Info(BaseModel):
11
+ start_time: str
12
+ end_time: str
13
+ date_distribution: list[Date_Distribution_Item]
@@ -0,0 +1,33 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class ObjectVariant(BaseModel):
5
+ """A single object variant: a distinct activity sequence objects of a type follow.
6
+
7
+ Attributes:
8
+ variant_id: Identifier of the variant
9
+ (``<object_type>_<hash of the activity sequence>``). It depends only
10
+ on the sequence, so it stays the same across filtered views of a log.
11
+ activities: The ordered activity sequence that defines the variant.
12
+ event_count: Number of events in the variant (length of ``activities``).
13
+ case_count: Number of objects (cases) that follow this variant.
14
+ """
15
+
16
+ variant_id: str
17
+ activities: list[str]
18
+ event_count: int
19
+ case_count: int
20
+
21
+
22
+ class ObjectTypeVariants(BaseModel):
23
+ """All variants of a single object type together with the totals across them.
24
+
25
+ Attributes:
26
+ variants: The distinct variants, sorted by frequency (descending).
27
+ case_count: Total number of cases (objects) of the object type.
28
+ event_count: Total number of events across all cases of the object type.
29
+ """
30
+
31
+ variants: list[ObjectVariant]
32
+ case_count: int
33
+ event_count: int
@@ -0,0 +1,19 @@
1
+ from fastapi import FastAPI
2
+ from ocelescope_backend.app.modules import Module, ModuleMeta
3
+ from packaging.version import Version
4
+
5
+ from ocelescope_module_ocel.routers import router
6
+
7
+
8
+ class Ocel(Module):
9
+ meta = ModuleMeta(key="ocel", version=Version("1.0"))
10
+
11
+ @classmethod
12
+ def create_app(cls) -> FastAPI:
13
+ app = FastAPI(
14
+ title="OCEL", version=str(cls.meta.version), docs_url=None, redoc_url=None
15
+ )
16
+
17
+ app.include_router(router)
18
+
19
+ return app
@@ -0,0 +1,28 @@
1
+ """The OCEL module's router, assembled from the per-resource sub-routers.
2
+
3
+ ``management`` is included first so its static single-segment paths (``/default``,
4
+ ``/extension/meta``) are matched before the ``/{ocel_id}`` catch-all.
5
+ """
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from ocelescope_module_ocel.routers import (
10
+ attributes,
11
+ events,
12
+ export,
13
+ management,
14
+ objects,
15
+ quantities,
16
+ relations,
17
+ )
18
+
19
+ router = APIRouter()
20
+ router.include_router(management.router)
21
+ router.include_router(attributes.router)
22
+ router.include_router(objects.router)
23
+ router.include_router(events.router)
24
+ router.include_router(relations.router)
25
+ router.include_router(quantities.router)
26
+ router.include_router(export.router)
27
+
28
+ __all__ = ["router"]
@@ -0,0 +1,80 @@
1
+ """Aggregated and per-type attribute summaries (computation in ``util.attributes``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Query
8
+ from ocelescope_backend.app.dependencies import ApiOcel
9
+ from ocelescope_backend.app.internal.model.base import PaginatedResponse
10
+
11
+ from ocelescope_module_ocel.models import AggregatedAttribute, TypedAttribute
12
+ from ocelescope_module_ocel.util import attributes as attribute_util
13
+ from ocelescope_module_ocel.util.attributes import EntityType
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("/{ocel_id}/attributes", operation_id="AggregatedAttributes")
19
+ def get_aggr_attributes(
20
+ ocel: ApiOcel,
21
+ page: Annotated[int, Query(ge=1)] = 1,
22
+ page_size: Annotated[int, Query()] = 10,
23
+ entity_type: Annotated[EntityType, Query()] = "events",
24
+ attribute_names: Annotated[list[str] | None, Query()] = None,
25
+ entity_names: Annotated[list[str] | None, Query()] = None,
26
+ drop_constant: Annotated[bool, Query()] = False,
27
+ ) -> PaginatedResponse[list[AggregatedAttribute]]:
28
+ names = attribute_util.attribute_names(
29
+ ocel, entity_type, attribute_names, entity_names, drop_constant
30
+ )
31
+ page_names = names[(page - 1) * page_size : page * page_size]
32
+ table = attribute_util.merged_table(ocel, entity_type, page_names, entity_names)
33
+
34
+ return PaginatedResponse(
35
+ page=page,
36
+ page_size=page_size,
37
+ total_items=len(names),
38
+ response=attribute_util.aggregate_attributes(table),
39
+ )
40
+
41
+
42
+ @router.get(
43
+ "/{ocel_id}/objects/attributes",
44
+ response_model=list[TypedAttribute],
45
+ operation_id="objectAttributes",
46
+ )
47
+ def get_object_attributes(
48
+ ocel: ApiOcel,
49
+ attribute_names: Annotated[list[str], Query()] = [],
50
+ names: Annotated[list[str] | None, Query()] = None,
51
+ ) -> list[TypedAttribute]:
52
+ table = attribute_util.merged_object_table(ocel, attribute_names or None, names)
53
+ return attribute_util.typed_attributes(table)
54
+
55
+
56
+ @router.get(
57
+ "/{ocel_id}/events/attributes",
58
+ response_model=list[TypedAttribute],
59
+ operation_id="eventAttributes",
60
+ )
61
+ def get_event_attributes(
62
+ ocel: ApiOcel,
63
+ attribute_names: Annotated[list[str], Query()] = [],
64
+ names: Annotated[list[str] | None, Query()] = None,
65
+ ) -> list[TypedAttribute]:
66
+ table = attribute_util.merged_event_table(ocel, attribute_names or None, names)
67
+ return attribute_util.typed_attributes(table)
68
+
69
+
70
+ @router.get("/{ocel_id}/attribute/names", operation_id="AttributeNames")
71
+ def get_attribute_names(
72
+ ocel: ApiOcel,
73
+ entity_type: Annotated[EntityType | None, Query()] = None,
74
+ ) -> list[str]:
75
+ names: list[str] = []
76
+ if entity_type != "events":
77
+ names += attribute_util.attribute_names(ocel, "objects")
78
+ if entity_type != "objects":
79
+ names += attribute_util.attribute_names(ocel, "events")
80
+ return names
@@ -0,0 +1,104 @@
1
+ """Event-centric summaries: activities, counts, ids and the time distribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ import pandas as pd
8
+ from fastapi import APIRouter
9
+ from ocelescope.ocel.constants.pm4py import ACTIVITY_COL, EID_COL, TIMESTAMP_COL
10
+ from ocelescope_backend.app.dependencies import ApiOcel
11
+ from ocelescope_backend.app.internal.model.base import PaginatedResponse
12
+
13
+ from ocelescope_module_ocel.models import Date_Distribution_Item, Entity_Time_Info
14
+ from ocelescope_module_ocel.util.pagination import paginate_ids
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get("/{ocel_id}/events/activityNames", operation_id="Activities")
20
+ def get_activities(ocel: ApiOcel) -> list[str]:
21
+ return ocel.events.activities
22
+
23
+
24
+ @router.get(
25
+ "/{ocel_id}/events/counts",
26
+ response_model=dict[str, int],
27
+ operation_id="eventCounts",
28
+ )
29
+ def get_event_counts(ocel: ApiOcel) -> dict[str, int]:
30
+ # Ordered most frequent first, which the response preserves.
31
+ return {
32
+ str(activity): int(count)
33
+ for activity, count in ocel.events.activity_counts.items()
34
+ }
35
+
36
+
37
+ @router.get("/{ocel_id}/events/ids", operation_id="eventIds")
38
+ def get_event_ids(
39
+ ocel: ApiOcel,
40
+ search: str | None = None,
41
+ size: int = 10,
42
+ page: int = 1,
43
+ ) -> PaginatedResponse[list[str]]:
44
+ return paginate_ids(ocel.events.table, EID_COL, search, page, size)
45
+
46
+
47
+ @router.get(
48
+ "/{ocel_id}/events/time",
49
+ response_model=Entity_Time_Info,
50
+ operation_id="timeInfo",
51
+ )
52
+ def get_time_info(
53
+ ocel: ApiOcel, periods: int | None = None, freq: str | None = None
54
+ ) -> Entity_Time_Info:
55
+
56
+ span = ocel.events.table.aggregate(
57
+ f'min("{TIMESTAMP_COL}") AS start, max("{TIMESTAMP_COL}") AS end'
58
+ ).fetchone()
59
+ assert span is not None
60
+ start_time, end_time = span
61
+
62
+ bins = pd.date_range(start_time, end_time, periods=periods, freq=freq)
63
+ if len(bins) < 2:
64
+ return Entity_Time_Info(
65
+ start_time=start_time.isoformat(),
66
+ end_time=end_time.isoformat(),
67
+ date_distribution=[],
68
+ )
69
+ edges = [edge.to_pydatetime() for edge in bins]
70
+
71
+ rows = ocel.sql(
72
+ f"""
73
+ WITH bounds AS (SELECT ? AS edges),
74
+ windows AS (
75
+ SELECT i AS window_id, edges[i + 1] AS win_start, edges[i + 2] AS win_end
76
+ FROM bounds, range(0, len(edges) - 1) AS steps(i)
77
+ )
78
+ SELECT w.window_id, e."{ACTIVITY_COL}" AS activity, count(*) AS count
79
+ FROM events e JOIN windows w
80
+ ON (e."{TIMESTAMP_COL}" > w.win_start AND e."{TIMESTAMP_COL}" <= w.win_end)
81
+ OR (w.window_id = 0 AND e."{TIMESTAMP_COL}" = w.win_start)
82
+ GROUP BY w.window_id, e."{ACTIVITY_COL}"
83
+ """,
84
+ [edges],
85
+ ).fetchall()
86
+
87
+ counts_by_window: dict[int, dict[str, int]] = defaultdict(dict)
88
+ for window_id, activity, count in rows:
89
+ counts_by_window[window_id][activity] = int(count)
90
+
91
+ date_distribution = [
92
+ Date_Distribution_Item(
93
+ start_timestamp=edges[window_id].isoformat(),
94
+ end_timestamp=edges[window_id + 1].isoformat(),
95
+ entity_count=counts_by_window[window_id],
96
+ )
97
+ for window_id in sorted(counts_by_window)
98
+ ]
99
+
100
+ return Entity_Time_Info(
101
+ start_time=start_time.isoformat(),
102
+ end_time=end_time.isoformat(),
103
+ date_distribution=date_distribution,
104
+ )
@@ -0,0 +1,108 @@
1
+ """Download the OCEL, or a flattened XES log.
2
+
3
+ ``downloadOCEL`` streams the log straight from the DuckDB store (no materialization).
4
+ The two XES routes flatten a chosen object type into a per-object trace log, which
5
+ is pm4py-only, so those do pull the tables they need into memory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Annotated, Literal
13
+
14
+ import polars as pl
15
+ from fastapi import APIRouter, Query
16
+ from ocelescope.ocel import Keep
17
+ from ocelescope.ocel.constants.misc import OCELFileExtensions
18
+ from ocelescope.ocel.constants.pm4py import OID_COL
19
+ from ocelescope_backend.app.dependencies import ApiOcel, ApiSession
20
+ from ocelescope_backend.app.internal.exceptions import NotFound
21
+ from ocelescope_backend.app.internal.model.response import TempFileResponse
22
+
23
+ from ocelescope import OCEL, BaseFilter
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ class _ObjectIdKeep(BaseFilter):
29
+ """A ModuleFilter that keeps only the given object ids (for a variant sub-log)."""
30
+
31
+ object_ids: list[str]
32
+
33
+ def keep(self, ocel: OCEL) -> Keep:
34
+ return Keep(objects=pl.LazyFrame({OID_COL: self.object_ids}))
35
+
36
+
37
+ @router.get("/{ocel_id}/download", summary="Download OCEL", operation_id="downloadOCEL")
38
+ def download_ocel(
39
+ session: ApiSession,
40
+ ocel_id: str,
41
+ ext: OCELFileExtensions = ".json",
42
+ ocel_version: Literal["original", "filtered"] | None = "filtered",
43
+ ) -> TempFileResponse:
44
+ if ocel_id not in session.ocels:
45
+ raise NotFound("OCEL not found")
46
+
47
+ name = session.ocels[ocel_id].name
48
+ tmp_file_prefix = datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + name
49
+ file_response = TempFileResponse(
50
+ prefix=tmp_file_prefix, suffix=ext, filename=name + ext
51
+ )
52
+
53
+ session.export_ocel(
54
+ ocel_id, Path(file_response.tmp_path), use_original=ocel_version == "original"
55
+ )
56
+ return file_response
57
+
58
+
59
+ @router.get(
60
+ "/{ocel_id}/download/xes",
61
+ summary="Download OCEL as a xes",
62
+ operation_id="downloadFlatLog",
63
+ )
64
+ def download_flat_log(
65
+ ocel: ApiOcel, ocel_id: str, session: ApiSession, object_type_name: str
66
+ ) -> TempFileResponse:
67
+ name = session.ocels[ocel_id].name
68
+ tmp_file_prefix = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{name}"
69
+ file_response = TempFileResponse(
70
+ prefix=tmp_file_prefix, suffix=".xes", filename=f"{name}_{object_type_name}.xes"
71
+ )
72
+
73
+ ocel.write_xes(object_type_name, Path(file_response.tmp_path))
74
+ return file_response
75
+
76
+
77
+ @router.get(
78
+ "/{ocel_id}/objects/variants/download/xes",
79
+ summary="Download selected object variants as a flat XES log",
80
+ description=(
81
+ "Filters the OCEL to the objects of `object_type` that follow any of the "
82
+ "given `variant_ids`, then flattens that sub-log by `object_type` into a "
83
+ "XES file (one trace per object)."
84
+ ),
85
+ operation_id="downloadVariantFlatLog",
86
+ )
87
+ def download_variant_flat_log(
88
+ ocel: ApiOcel,
89
+ session: ApiSession,
90
+ ocel_id: str,
91
+ object_type: str,
92
+ variant_ids: Annotated[list[str], Query(min_length=1)],
93
+ ) -> TempFileResponse:
94
+ name = session.ocels[ocel_id].name
95
+
96
+ object_ids = ocel.executions.get_variant_object_ids(object_type, variant_ids)
97
+
98
+ if not object_ids:
99
+ raise NotFound("No objects were found for the given variants")
100
+
101
+ tmp_file_prefix = datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + name
102
+ file_response = TempFileResponse(
103
+ prefix=tmp_file_prefix, suffix=".xes", filename=f"{name}_{object_type}.xes"
104
+ )
105
+
106
+ with ocel.filter([_ObjectIdKeep(object_ids=object_ids)]) as variant_ocel:
107
+ variant_ocel.write_xes(object_type, Path(file_response.tmp_path))
108
+ return file_response