keepup-http 0.5.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 (48) hide show
  1. keepup_http-0.5.0/PKG-INFO +29 -0
  2. keepup_http-0.5.0/README.md +10 -0
  3. keepup_http-0.5.0/keepup_http/__init__.py +38 -0
  4. keepup_http-0.5.0/keepup_http/api_docs.py +76 -0
  5. keepup_http-0.5.0/keepup_http/body_limit.py +156 -0
  6. keepup_http-0.5.0/keepup_http/events_api.py +237 -0
  7. keepup_http-0.5.0/keepup_http/factory.py +562 -0
  8. keepup_http-0.5.0/keepup_http/lock_routes.py +155 -0
  9. keepup_http-0.5.0/keepup_http/plugin.py +53 -0
  10. keepup_http-0.5.0/keepup_http/routes.py +549 -0
  11. keepup_http-0.5.0/keepup_http/static/css/aos.css +1 -0
  12. keepup_http-0.5.0/keepup_http/static/css/main_nebula.css +232 -0
  13. keepup_http-0.5.0/keepup_http/static/css/main_new.css +869 -0
  14. keepup_http-0.5.0/keepup_http/static/css/tailwind.css +1 -0
  15. keepup_http-0.5.0/keepup_http/static/index_nebula.html +282 -0
  16. keepup_http-0.5.0/keepup_http/static/index_new.html +275 -0
  17. keepup_http-0.5.0/keepup_http/static/js/aos.js +1 -0
  18. keepup_http-0.5.0/keepup_http/static/js/feather-icons.js +13 -0
  19. keepup_http-0.5.0/keepup_http/static/js/layout_bootstrap.js +29 -0
  20. keepup_http-0.5.0/keepup_http/static/js/main_new.js +2076 -0
  21. keepup_http-0.5.0/keepup_http/static/js/tailwind.js +83 -0
  22. keepup_http-0.5.0/keepup_http/static/modules/css/background_tasks.css +233 -0
  23. keepup_http-0.5.0/keepup_http/static/modules/css/cluster.css +16 -0
  24. keepup_http-0.5.0/keepup_http/static/modules/css/event_manager.css +386 -0
  25. keepup_http-0.5.0/keepup_http/static/modules/css/integration_logs.css +33 -0
  26. keepup_http-0.5.0/keepup_http/static/modules/css/metrics.css +115 -0
  27. keepup_http-0.5.0/keepup_http/static/modules/css/modules.css +189 -0
  28. keepup_http-0.5.0/keepup_http/static/modules/css/themes.css +563 -0
  29. keepup_http-0.5.0/keepup_http/static/modules/css/users.css +278 -0
  30. keepup_http-0.5.0/keepup_http/static/modules/js/background_tasks.js +695 -0
  31. keepup_http-0.5.0/keepup_http/static/modules/js/chart.js +14 -0
  32. keepup_http-0.5.0/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  33. keepup_http-0.5.0/keepup_http/static/modules/js/cluster.js +374 -0
  34. keepup_http-0.5.0/keepup_http/static/modules/js/event_manager.js +1007 -0
  35. keepup_http-0.5.0/keepup_http/static/modules/js/integration_logs.js +795 -0
  36. keepup_http-0.5.0/keepup_http/static/modules/js/metrics.js +929 -0
  37. keepup_http-0.5.0/keepup_http/static/modules/js/modules.js +1128 -0
  38. keepup_http-0.5.0/keepup_http/static/modules/js/themes.js +684 -0
  39. keepup_http-0.5.0/keepup_http/static/modules/js/users.js +898 -0
  40. keepup_http-0.5.0/keepup_http/web.py +214 -0
  41. keepup_http-0.5.0/keepup_http.egg-info/PKG-INFO +29 -0
  42. keepup_http-0.5.0/keepup_http.egg-info/SOURCES.txt +46 -0
  43. keepup_http-0.5.0/keepup_http.egg-info/dependency_links.txt +1 -0
  44. keepup_http-0.5.0/keepup_http.egg-info/entry_points.txt +2 -0
  45. keepup_http-0.5.0/keepup_http.egg-info/requires.txt +12 -0
  46. keepup_http-0.5.0/keepup_http.egg-info/top_level.txt +1 -0
  47. keepup_http-0.5.0/pyproject.toml +46 -0
  48. keepup_http-0.5.0/setup.cfg +4 -0
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-http
3
+ Version: 0.5.0
4
+ Summary: The way into the application over HTTP: the application, its routes and its middleware.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: keepup-db>=0.4.0
9
+ Requires-Dist: keepup-auth>=0.4.0
10
+ Requires-Dist: keepup-audit>=0.4.0
11
+ Requires-Dist: keepup-cluster>=0.4.0
12
+ Requires-Dist: keepup-modules>=0.4.0
13
+ Requires-Dist: keepup-metrics>=0.4.0
14
+ Requires-Dist: keepup-tasks>=0.4.0
15
+ Requires-Dist: keepup-ui>=0.4.0
16
+ Requires-Dist: fastapi<1,>=0.128
17
+ Requires-Dist: starlette<2,>=1.3.1
18
+ Requires-Dist: pydantic<3,>=2.11
19
+
20
+ # keepup-http
21
+
22
+ The way into the application over HTTP: the application, its routes and its middleware.
23
+
24
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
25
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
26
+ this distribution carries one part of it. It is installed on its own
27
+ (`pip install keepup-http`) or together with the base (`pip install "keepup-admin[panel]"`),
28
+ and it declares itself to the kernel through its
29
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,10 @@
1
+ # keepup-http
2
+
3
+ The way into the application over HTTP: the application, its routes and its middleware.
4
+
5
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
6
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
7
+ this distribution carries one part of it. It is installed on its own
8
+ (`pip install keepup-http`) or together with the base (`pip install "keepup-admin[panel]"`),
9
+ and it declares itself to the kernel through its
10
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,38 @@
1
+ """The way into the application over HTTP (keepup-121).
2
+
3
+ This distribution carries what the kernel does not need: the ASGI application,
4
+ the routes, the middleware, the panel shell and the documentation pages. It
5
+ declares one plugin, `http`, of the transport kind, and depends on `keepup-admin`
6
+ for the constructor it is loaded by -- the base itself imports no web framework.
7
+
8
+ The modules here were the base's `api_docs.py`, `events_api.py`, `web.py` and the
9
+ route wrapper; they move in one slice at a time, and the base answers their old
10
+ names through the compatibility layer for one release (keepup-122).
11
+ """
12
+
13
+ __version__ = "0.5.0"
14
+
15
+ __all__ = ["HttpPlugin", "SERVICE_HTTP", "register_api_documentation",
16
+ "register_event_api_routes"]
17
+
18
+ #: What a caller asks for to reach the application the transport builds.
19
+ SERVICE_HTTP = "http"
20
+
21
+ _FROM_PLUGIN = {"HttpPlugin", "SERVICE_HTTP"}
22
+ _FROM_MODULES = {
23
+ "register_api_documentation": "api_docs",
24
+ "register_event_api_routes": "events_api",
25
+ }
26
+
27
+
28
+ def __getattr__(name):
29
+ if name in _FROM_PLUGIN:
30
+ from keepup_http import plugin
31
+
32
+ return getattr(plugin, name)
33
+ if name in _FROM_MODULES:
34
+ import importlib
35
+
36
+ module = importlib.import_module(f"keepup_http.{_FROM_MODULES[name]}")
37
+ return getattr(module, name)
38
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,76 @@
1
+ """The API schema and the documentation pages built from it (keepup-96).
2
+
3
+ FastAPI serves its OpenAPI schema at /openapi.json to anybody, and the schema
4
+ describes every route of the application -- the administrative ones with their
5
+ parameters and bodies. For somebody outside that is a map of where to knock.
6
+ FastAPI has no notion of who is asking, so the framework takes both routes over:
7
+
8
+ - the schema answers an administrator only, like every other administrative
9
+ route of the framework, unless the application opens it on purpose
10
+ (``KeepupSettings.openapi_public``) or removes it (``openapi_url=None``);
11
+ - the documentation pages stay public shells: they fetch the schema from the
12
+ browser, with the panel's cookie, so an administrator sees the API and anybody
13
+ else sees the refusal.
14
+
15
+ Building the schema in code -- ``app.openapi()``, which ci/generate_openapi.py
16
+ calls -- is untouched: it never went through the route.
17
+ """
18
+
19
+ from fastapi import Depends
20
+ from fastapi.openapi.docs import (
21
+ get_redoc_html,
22
+ get_swagger_ui_html,
23
+ get_swagger_ui_oauth2_redirect_html,
24
+ )
25
+ from fastapi.responses import JSONResponse
26
+
27
+ from keepup_auth.dependencies import get_current_admin
28
+
29
+ #: What an application may import from this module. Everything else is
30
+ #: internal and may change without notice -- see doc/keepup.md.
31
+ __all__ = ["OAUTH2_REDIRECT_URL", "register_api_documentation"]
32
+
33
+ #: Where Swagger UI returns from an OAuth2 sign-in; FastAPI's own default.
34
+ OAUTH2_REDIRECT_URL = "/docs/oauth2-redirect"
35
+
36
+
37
+ def register_api_documentation(app, settings) -> None:
38
+ """The schema route and the documentation pages, as the settings ask.
39
+
40
+ Called right after the application is created: FastAPI used to register
41
+ these in its constructor, ahead of any route of the application, and a page
42
+ registered later could be shadowed by a route that happens to match it.
43
+ """
44
+ schema_url = settings.openapi_url
45
+ if not schema_url:
46
+ # No schema, no pages: they would have nothing to show.
47
+ return
48
+
49
+ if settings.openapi_public:
50
+ async def api_schema():
51
+ return JSONResponse(app.openapi())
52
+ else:
53
+ async def api_schema(admin: dict = Depends(get_current_admin)):
54
+ return JSONResponse(app.openapi())
55
+
56
+ app.add_api_route(schema_url, api_schema, methods=["GET"], include_in_schema=False)
57
+
58
+ if settings.docs_url:
59
+ async def swagger_page():
60
+ return get_swagger_ui_html(openapi_url=schema_url, title=f"{app.title} - API",
61
+ oauth2_redirect_url=OAUTH2_REDIRECT_URL)
62
+
63
+ async def swagger_redirect():
64
+ return get_swagger_ui_oauth2_redirect_html()
65
+
66
+ app.add_api_route(settings.docs_url, swagger_page, methods=["GET"],
67
+ include_in_schema=False)
68
+ app.add_api_route(OAUTH2_REDIRECT_URL, swagger_redirect, methods=["GET"],
69
+ include_in_schema=False)
70
+
71
+ if settings.redoc_url:
72
+ async def redoc_page():
73
+ return get_redoc_html(openapi_url=schema_url, title=f"{app.title} - API")
74
+
75
+ app.add_api_route(settings.redoc_url, redoc_page, methods=["GET"],
76
+ include_in_schema=False)
@@ -0,0 +1,156 @@
1
+ """How large a request body a route accepts, checked before the body is read.
2
+
3
+ ``KeepupSettings.max_upload_bytes`` was declared and enforced nowhere: every
4
+ route read whatever it was sent. It is enforced here, as ASGI middleware, and
5
+ per route (keepup-49):
6
+
7
+ - **A route that declares ``max_body_bytes`` gets exactly that** -- a number of
8
+ bytes, or ``None`` for no limit. That is how a route that legitimately takes
9
+ gigabytes (a machine image) says so, and how one that takes a few kilobytes
10
+ asks for less.
11
+ - **A route whose body the framework reads** -- a plain write route, whose JSON
12
+ is parsed before the handler runs, and every route of the framework itself --
13
+ gets the application's ``max_upload_bytes`` when it declares nothing.
14
+ - **A route that reads its own body** (``is_upload``, ``raw_request``) and
15
+ declares nothing gets no limit from the framework. Such routes stream files
16
+ and proxy other people's traffic; a general number applied to them the day
17
+ the framework starts enforcing would cut off an upload nobody had sized, so
18
+ each of them states its own.
19
+
20
+ The check comes before the body is read. A request whose ``Content-Length``
21
+ already says too much is answered 413 without the application seeing it. A
22
+ body without one (chunked) is counted as it arrives: once it passes the limit
23
+ the application is told the client went away, and whatever it answers is
24
+ replaced with the 413 -- the handler may have run with a partial read, which is
25
+ why a declared length is the path that matters.
26
+ """
27
+
28
+ import json
29
+ import logging
30
+ from typing import Optional
31
+
32
+ from starlette.routing import Match
33
+
34
+ from keepup.logging_setup import for_log
35
+
36
+ #: What an application may import from this module. Everything else is
37
+ #: internal and may change without notice -- see doc/keepup.md.
38
+ __all__ = [
39
+ "BodyLimitMiddleware",
40
+ "ROUTE_LIMIT_ATTRIBUTE",
41
+ "declare_limit",
42
+ ]
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ #: The attribute a route's endpoint carries when the route declared a limit.
47
+ ROUTE_LIMIT_ATTRIBUTE = "keepup_max_body_bytes"
48
+ #: The attribute of an endpoint that reads its own body.
49
+ READS_OWN_BODY_ATTRIBUTE = "keepup_reads_own_body"
50
+
51
+ _NOT_DECLARED = object()
52
+ #: Methods whose requests are not checked. Everything else is: a route declared
53
+ #: with OPTIONS or a custom method beside a write reads its body as well.
54
+ _BODYLESS_METHODS = {"GET", "HEAD"}
55
+
56
+
57
+ def declare_limit(endpoint, route: dict) -> None:
58
+ """Mark a plugin route's endpoint with what its declaration says about bodies."""
59
+ limit = route.get('max_body_bytes', _NOT_DECLARED)
60
+ if limit is not _NOT_DECLARED:
61
+ if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool)
62
+ or limit <= 0):
63
+ raise ValueError(f"{route.get('path')}: max_body_bytes must be a positive "
64
+ f"number of bytes or None, not {limit!r}")
65
+ setattr(endpoint, ROUTE_LIMIT_ATTRIBUTE, limit)
66
+ if route.get("is_upload") or route.get("raw_request"):
67
+ setattr(endpoint, READS_OWN_BODY_ATTRIBUTE, True)
68
+
69
+
70
+ class BodyLimitMiddleware:
71
+ """Refuses a body larger than its route accepts, before the route reads it."""
72
+
73
+ def __init__(self, app, router_of=None, default_limit: Optional[int] = None):
74
+ self.app = app
75
+ self.router_of = router_of
76
+ self.default_limit = default_limit
77
+
78
+ def limit_for(self, scope) -> Optional[int]:
79
+ """The limit of the route this request goes to; None for none."""
80
+ router = getattr(self.router_of, "router", None)
81
+ for route in getattr(router, "routes", ()):
82
+ match, _ = route.matches(scope)
83
+ if match == Match.FULL:
84
+ endpoint = getattr(route, "endpoint", None)
85
+ declared = getattr(endpoint, ROUTE_LIMIT_ATTRIBUTE, _NOT_DECLARED)
86
+ if declared is not _NOT_DECLARED:
87
+ return declared
88
+ if getattr(endpoint, READS_OWN_BODY_ATTRIBUTE, False):
89
+ return None
90
+ return self.default_limit
91
+ return self.default_limit
92
+
93
+ async def __call__(self, scope, receive, send):
94
+ if scope["type"] != "http" or scope.get("method") in _BODYLESS_METHODS:
95
+ return await self.app(scope, receive, send)
96
+ limit = self.limit_for(scope)
97
+ if limit is None:
98
+ return await self.app(scope, receive, send)
99
+
100
+ declared = _content_length(scope)
101
+ if declared is not None and declared > limit:
102
+ logger.warning("Request body refused: %s %s declares %s bytes, the route "
103
+ "accepts %s", scope.get("method"), for_log(scope.get("path")),
104
+ declared, limit)
105
+ return await _answer_too_large(send, limit)
106
+
107
+ state = {"received": 0, "over": False, "started": False}
108
+
109
+ async def counted_receive():
110
+ if state["over"]:
111
+ return {"type": "http.disconnect"}
112
+ message = await receive()
113
+ if message["type"] == "http.request":
114
+ state["received"] += len(message.get("body", b""))
115
+ if state["received"] > limit:
116
+ state["over"] = True
117
+ logger.warning("Request body refused: %s %s passed %s bytes without "
118
+ "declaring its length", scope.get("method"),
119
+ for_log(scope.get("path")), limit)
120
+ return {"type": "http.disconnect"}
121
+ return message
122
+
123
+ async def guarded_send(message):
124
+ if state["over"]:
125
+ return
126
+ if message["type"] == "http.response.start":
127
+ state["started"] = True
128
+ await send(message)
129
+
130
+ try:
131
+ await self.app(scope, counted_receive, guarded_send)
132
+ except Exception:
133
+ if not state["over"]:
134
+ raise
135
+ if state["over"] and not state["started"]:
136
+ await _answer_too_large(send, limit)
137
+
138
+
139
+ def _content_length(scope) -> Optional[int]:
140
+ for name, value in scope.get("headers", ()):
141
+ if name == b"content-length":
142
+ try:
143
+ return int(value)
144
+ except ValueError:
145
+ return None
146
+ return None
147
+
148
+
149
+ async def _answer_too_large(send, limit: int) -> None:
150
+ body = json.dumps({"detail": f"Request body too large: this route accepts "
151
+ f"at most {limit} bytes"}).encode()
152
+ await send({"type": "http.response.start", "status": 413,
153
+ "headers": [(b"content-type", b"application/json"),
154
+ (b"content-length", str(len(body)).encode()),
155
+ (b"connection", b"close")]})
156
+ await send({"type": "http.response.body", "body": body})
@@ -0,0 +1,237 @@
1
+ """The HTTP routes of the application's event log.
2
+
3
+ The journal itself -- the table, writing, reading, retention -- is
4
+ `keepup/events.py`; these routes are one reader of it (keepup-23). Every route
5
+ is for administrators: the journal holds sign-ins, agent connections and
6
+ console entries of every user. See `doc/event_manager.md`.
7
+ """
8
+
9
+ import logging
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from fastapi import Depends, HTTPException, Query
14
+ from pydantic import BaseModel, Field
15
+
16
+ from keepup_auth.dependencies import get_current_admin
17
+ from keepup_audit import events as events
18
+ from keepup_audit import trail as admin_trail
19
+ from keepup.logging_setup import for_log
20
+
21
+ #: What an application may import from this module. Everything else is
22
+ #: internal and may change without notice -- see doc/keepup.md.
23
+ __all__ = [
24
+ "register_event_api_routes",
25
+ ]
26
+
27
+ # The journal's manager is looked up on its module at each call
28
+ # (``events.event_manager``), not bound here: whoever replaces the journal's
29
+ # manager -- an application, a test -- replaces it for the routes too.
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ #: What a failed route answers. The exception's own text -- a database message
34
+ #: naming tables, values and sometimes a query -- went to the client and to the
35
+ #: audit as it was (keepup-76); it goes to the application log instead.
36
+ INTERNAL_ERROR = "Internal server error; see the application log"
37
+
38
+
39
+ class AppEvent(BaseModel):
40
+ """Application event."""
41
+ id: Optional[int] = None
42
+ event_type: str = Field(..., description="Event type, for example 'user_login', 'payment_success', 'error'")
43
+ event_text: str = Field(..., description="Event text")
44
+ event_data: Optional[Dict[str, Any]] = Field(None, description="Extra event payload, as JSON")
45
+ instance_id: Optional[str] = Field(None, description="Id of the instance that produced the event")
46
+ instance_name: Optional[str] = Field(None, description="Name of the instance that produced the event")
47
+ created_at: Optional[datetime] = Field(None, description="When the event was created")
48
+
49
+
50
+ class EventCreate(BaseModel):
51
+ """Payload for creating an event."""
52
+ event_type: str = Field(..., min_length=1, max_length=100, description="Event type")
53
+ event_text: str = Field(..., min_length=1, description="Event text")
54
+ event_data: Optional[Dict[str, Any]] = Field(None, description="Extra payload")
55
+
56
+
57
+ class EventListResponse(BaseModel):
58
+ """Response carrying a list of events."""
59
+ events: List[Dict[str, Any]]
60
+ total: int
61
+ page: int
62
+ page_size: int
63
+ total_pages: int
64
+
65
+
66
+
67
+ def register_event_api_routes(app, declared_event_types=None):
68
+ """Register the event API routes on the application.
69
+
70
+ ``declared_event_types`` is supplied by the application: a callable
71
+ returning the event types it declares in code but may never have emitted.
72
+ The framework has no catalogue of its own -- which types exist is a
73
+ property of the application, and reaching into it from here would point
74
+ the dependency the wrong way.
75
+ """
76
+
77
+ @app.post("/api/events", response_model=Dict[str, Any])
78
+ async def create_event_endpoint(
79
+ event: EventCreate,
80
+ current_user: dict = Depends(get_current_admin)
81
+ ):
82
+ """Create an event by hand.
83
+
84
+ Administrators only. Audit events are written by the server itself at
85
+ the point the action happens (the application's audit helper), never
86
+ accepted over HTTP: a journal the watched party can fill is not
87
+ evidence. So a type the application declares, or one the framework
88
+ writes itself, is refused here, and what is accepted carries who wrote
89
+ it by hand (keepup-67). The event is attached to the current instance.
90
+ """
91
+ reserved = set(admin_trail.ADMIN_EVENT_TYPES)
92
+ if declared_event_types:
93
+ reserved.update(declared_event_types() or ())
94
+ if event.event_type in reserved:
95
+ raise HTTPException(
96
+ status_code=400,
97
+ detail="This event type is written by the application itself and "
98
+ "cannot be created by hand.")
99
+ data = dict(event.event_data or {})
100
+ data["manual"] = {"user_id": current_user.get("id"),
101
+ "username": current_user.get("username")}
102
+ try:
103
+ event_id = await events.event_manager.create_event(
104
+ event_type=event.event_type,
105
+ event_text=event.event_text,
106
+ event_data=data
107
+ )
108
+
109
+ return {
110
+ "success": True,
111
+ "event_id": event_id,
112
+ "message": "Event created successfully",
113
+ "instance": {
114
+ "id": events.event_manager.instance_id,
115
+ "name": events.event_manager.instance_name
116
+ }
117
+ }
118
+ except Exception as e:
119
+ logger.error("Error in create_event_endpoint: %s", for_log(e))
120
+ raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
121
+
122
+ @app.get("/api/events", response_model=EventListResponse)
123
+ async def get_events_endpoint(
124
+ event_type: Optional[str] = Query(None, description="Filter by event type"),
125
+ instance_id: Optional[str] = Query(None, description="Filter by instance id"),
126
+ instance_name: Optional[str] = Query(None, description="Filter by instance name"),
127
+ start_date: Optional[datetime] = Query(None, description="Lower date bound, ISO format"),
128
+ end_date: Optional[datetime] = Query(None, description="Upper date bound, ISO format"),
129
+ page: int = Query(1, ge=1, description="Page number"),
130
+ page_size: int = Query(50, ge=1, le=500, description="Page size"),
131
+ current_user: dict = Depends(get_current_admin)
132
+ ):
133
+ """Return events, filtered and paginated.
134
+
135
+ Administrators only: the journal holds logins, agent connections and
136
+ console entries of every user, which is the administrator's view of
137
+ the platform and not the user's own.
138
+ """
139
+ try:
140
+ result = await events.event_manager.get_events(
141
+ event_type=event_type,
142
+ instance_id=instance_id,
143
+ instance_name=instance_name,
144
+ start_date=start_date,
145
+ end_date=end_date,
146
+ page=page,
147
+ page_size=page_size
148
+ )
149
+
150
+ return result
151
+ except Exception as e:
152
+ logger.error("Error in get_events_endpoint: %s", for_log(e))
153
+ raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
154
+
155
+ @app.get("/api/events/types")
156
+ async def get_event_types_endpoint(
157
+ current_user: dict = Depends(get_current_admin)
158
+ ):
159
+ """Every type the filter may offer: seen in the journal, or declared.
160
+
161
+ A list built only from what the table holds cannot offer a type that
162
+ has never occurred -- which is exactly the one an administrator asks
163
+ for when checking whether something happened at all. The declared
164
+ audit types are therefore added to the observed ones.
165
+
166
+ The declared types are supplied by the application through
167
+ ``register_event_api_routes``; the framework does not import the
168
+ application's audit catalogue.
169
+ """
170
+ try:
171
+ declared = list(declared_event_types() or ()) if declared_event_types else []
172
+
173
+ observed = await events.event_manager.get_event_types()
174
+ event_types = sorted(set(observed) | set(declared))
175
+ return {
176
+ "event_types": event_types,
177
+ "count": len(event_types)
178
+ }
179
+ except Exception as e:
180
+ logger.error("Error in get_event_types_endpoint: %s", for_log(e))
181
+ raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
182
+
183
+ @app.get("/api/events/instances")
184
+ async def get_instances_endpoint(
185
+ current_user: dict = Depends(get_current_admin)
186
+ ):
187
+ """Return every instance that has produced events (administrators only)."""
188
+ try:
189
+ instances = await events.event_manager.get_instances()
190
+ return {
191
+ "instances": instances,
192
+ "count": len(instances),
193
+ "current_instance": {
194
+ "id": events.event_manager.instance_id,
195
+ "name": events.event_manager.instance_name
196
+ }
197
+ }
198
+ except Exception as e:
199
+ logger.error("Error in get_instances_endpoint: %s", for_log(e))
200
+ raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
201
+
202
+ @app.delete("/api/events/cleanup")
203
+ async def cleanup_old_events_endpoint(
204
+ days: int = Query(30, ge=1, le=365, description="Delete events older than N days"),
205
+ current_user: dict = Depends(get_current_admin)
206
+ ):
207
+ """Delete old events (administrators only).
208
+
209
+ The purge itself is written into the log afterwards, so what it
210
+ removed is not all that is left of it (keepup-67).
211
+ """
212
+ try:
213
+ deleted_count = await events.event_manager.delete_old_events(days)
214
+ await admin_trail.record(
215
+ admin_trail.EVENTS_PURGED, current_user,
216
+ f"Deleted {deleted_count} events older than {days} days",
217
+ days=days, deleted_count=deleted_count)
218
+ return {
219
+ "success": True,
220
+ "deleted_count": deleted_count,
221
+ "message": f"Deleted {deleted_count} events older than {days} days"
222
+ }
223
+ except Exception as e:
224
+ logger.error("Error in cleanup_old_events_endpoint: %s", for_log(e))
225
+ raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
226
+
227
+ @app.get("/api/events/stats")
228
+ async def get_events_stats_endpoint(
229
+ current_user: dict = Depends(get_current_admin)
230
+ ):
231
+ """Return extended event statistics (administrators only)."""
232
+ try:
233
+ stats = await events.event_manager.get_events_stats()
234
+ return stats
235
+ except Exception as e:
236
+ logger.error("Error in get_events_stats_endpoint: %s", for_log(e))
237
+ raise HTTPException(status_code=500, detail=INTERNAL_ERROR)